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 closed_transport_never_invokes_network_and_reopen_is_host_owned() {
1395        let mut transport = CountingRealtimeTransport::default();
1396        let path = std::env::temp_dir().join(format!(
1397            "syncular-transport-gate-{}.db",
1398            uuid::Uuid::new_v4()
1399        ));
1400        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
1401            {"name":"id","type":"string","nullable":false},
1402            {"name":"project_id","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
1403        let mut client = SyncClient::open_path(
1404            "gate".into(),
1405            &schema,
1406            ClientLimits::default(),
1407            path.to_str().unwrap(),
1408        )
1409        .unwrap();
1410        client.connect_realtime(&mut transport).unwrap();
1411        client.set_transport_enabled(&mut transport, false);
1412        assert_eq!(transport.closes, 1);
1413        client.begin_security_preflight();
1414        client.activate_security(Default::default()).unwrap();
1415        client
1416            .subscribe(
1417                "tasks".into(),
1418                "tasks".into(),
1419                vec![("project_id".into(), vec!["p1".into()])],
1420                None,
1421            )
1422            .unwrap();
1423        for id in ["one", "two"] {
1424            client
1425                .mutate(vec![Mutation::Upsert {
1426                    table: "tasks".into(),
1427                    values: Map::from_iter([
1428                        ("id".into(), json!(id)),
1429                        ("project_id".into(), json!("p1")),
1430                    ]),
1431                    base_version: None,
1432                }])
1433                .unwrap();
1434        }
1435        assert_eq!(
1436            client
1437                .query("SELECT id FROM tasks ORDER BY id", &[])
1438                .unwrap()
1439                .len(),
1440            2
1441        );
1442        assert!(
1443            matches!(client.sync(&mut transport), SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.offline")
1444        );
1445        assert!(client
1446            .connect_realtime(&mut transport)
1447            .unwrap_err()
1448            .starts_with("sync.offline:"));
1449        assert!(client
1450            .set_presence(&mut transport, "project:p1", None)
1451            .unwrap_err()
1452            .starts_with("sync.offline:"));
1453        assert_eq!(transport.sync_calls, 0);
1454        assert_eq!(transport.realtime_sync_calls, 0);
1455        assert_eq!(transport.connects, 1);
1456        assert!(transport.messages.is_empty());
1457        assert!(matches!(
1458            client.drain_sync_intents().as_slice(),
1459            [SyncIntent::None]
1460        ));
1461        let pending = client.pending_commit_ids();
1462        drop(client);
1463        let mut reopened = SyncClient::open_path(
1464            "gate".into(),
1465            &schema,
1466            ClientLimits::default(),
1467            path.to_str().unwrap(),
1468        )
1469        .unwrap();
1470        assert!(reopened.transport_enabled());
1471        reopened.set_transport_enabled(&mut transport, false);
1472        assert_eq!(reopened.pending_commit_ids(), pending);
1473        assert_eq!(
1474            reopened.query("SELECT id FROM tasks", &[]).unwrap().len(),
1475            2
1476        );
1477        reopened.set_transport_enabled(&mut transport, true);
1478        assert!(matches!(
1479            reopened.drain_sync_intents().as_slice(),
1480            [SyncIntent::Interactive]
1481        ));
1482        reopened.set_transport_enabled(&mut transport, true);
1483        assert!(reopened.drain_sync_intents().is_empty());
1484        drop(reopened);
1485        std::fs::remove_file(path).unwrap();
1486    }
1487
1488    #[test]
1489    fn realtime_connection_ownership_is_idempotent() {
1490        let mut client = client();
1491        let mut transport = CountingRealtimeTransport {
1492            connects: 0,
1493            closes: 0,
1494            ..Default::default()
1495        };
1496        client
1497            .connect_realtime(&mut transport)
1498            .expect("first connect");
1499        client
1500            .connect_realtime(&mut transport)
1501            .expect("idempotent connect");
1502        assert_eq!(transport.connects, 1);
1503        client.disconnect_realtime(&mut transport);
1504        client.disconnect_realtime(&mut transport);
1505        assert_eq!(transport.closes, 1);
1506        client
1507            .connect_realtime(&mut transport)
1508            .expect("deliberate reconnect");
1509        assert_eq!(transport.connects, 2);
1510    }
1511
1512    #[test]
1513    fn required_policy_refuses_a_round_without_a_socket_and_never_uses_http() {
1514        let mut client = client();
1515        client
1516            .set_realtime_policy(RealtimePolicy::Required)
1517            .expect("set policy");
1518        let mut transport = CountingRealtimeTransport::default();
1519        match client.sync(&mut transport) {
1520            SyncOutcome::RealtimeUnavailable {
1521                state,
1522                reason_code,
1523                retry_delay_ms,
1524            } => {
1525                assert_eq!(state, RealtimeState::Disconnected);
1526                assert_eq!(reason_code, None);
1527                assert_eq!(retry_delay_ms, 250);
1528            }
1529            other => panic!("expected RealtimeUnavailable, got {other:?}"),
1530        }
1531        assert_eq!(transport.sync_calls, 0);
1532        assert!(matches!(
1533            client.drain_sync_intents().as_slice(),
1534            [SyncIntent::Background { delay_ms: 250 }]
1535        ));
1536        let snapshot = client
1537            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1538            .expect("diagnostics");
1539        assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
1540        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Required);
1541        assert_eq!(snapshot.host.realtime_reason_code, None);
1542        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
1543        assert_eq!(
1544            snapshot
1545                .last_round
1546                .as_ref()
1547                .and_then(|round| round.error_code.clone()),
1548            Some(REALTIME_UNAVAILABLE_CODE.to_owned())
1549        );
1550    }
1551
1552    #[test]
1553    fn refused_handshake_is_a_visible_state_and_a_required_round_does_not_fall_back() {
1554        let mut client = client();
1555        client
1556            .set_realtime_policy(RealtimePolicy::Required)
1557            .expect("set policy");
1558        let mut transport = CountingRealtimeTransport {
1559            refuse_connect: true,
1560            ..Default::default()
1561        };
1562        assert!(client.connect_realtime(&mut transport).is_err());
1563        assert_eq!(client.realtime_state(), RealtimeState::Refused);
1564        let snapshot = client
1565            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1566            .expect("diagnostics");
1567        assert_eq!(
1568            snapshot.host.realtime_reason_code.as_deref(),
1569            Some("transport.failed")
1570        );
1571        match client.sync(&mut transport) {
1572            SyncOutcome::RealtimeUnavailable {
1573                state, reason_code, ..
1574            } => {
1575                assert_eq!(state, RealtimeState::Refused);
1576                assert_eq!(reason_code.as_deref(), Some("transport.failed"));
1577            }
1578            other => panic!("expected RealtimeUnavailable, got {other:?}"),
1579        }
1580        assert_eq!(transport.sync_calls, 0);
1581        assert_eq!(transport.realtime_sync_calls, 0);
1582    }
1583
1584    #[test]
1585    fn a_failed_socket_round_is_lost_and_the_next_required_round_refuses_http() {
1586        let mut client = client();
1587        client
1588            .set_realtime_policy(RealtimePolicy::Required)
1589            .expect("set policy");
1590        let mut transport = CountingRealtimeTransport::default();
1591        client.connect_realtime(&mut transport).expect("connect");
1592        assert_eq!(client.realtime_state(), RealtimeState::Connected);
1593        assert!(
1594            client.set_realtime_policy(RealtimePolicy::Off).is_err(),
1595            "a policy change cannot silently re-route a connected socket"
1596        );
1597        assert!(matches!(
1598            client.sync(&mut transport),
1599            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.transport_failed"
1600        ));
1601        assert_eq!(client.realtime_state(), RealtimeState::Lost);
1602        assert_eq!(transport.realtime_sync_calls, 1);
1603        assert_eq!(transport.sync_calls, 0);
1604        let snapshot = client
1605            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1606            .expect("diagnostics");
1607        assert_eq!(
1608            snapshot.host.realtime_reason_code.as_deref(),
1609            Some("sync.transport_failed")
1610        );
1611        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
1612        match client.sync(&mut transport) {
1613            SyncOutcome::RealtimeUnavailable {
1614                state, reason_code, ..
1615            } => {
1616                assert_eq!(state, RealtimeState::Lost);
1617                assert_eq!(reason_code.as_deref(), Some("sync.transport_failed"));
1618            }
1619            other => panic!("expected RealtimeUnavailable, got {other:?}"),
1620        }
1621        assert_eq!(transport.sync_calls, 0);
1622        client.disconnect_realtime(&mut transport);
1623        assert_eq!(client.realtime_state(), RealtimeState::Disconnected);
1624        assert_eq!(transport.closes, 1);
1625        client.connect_realtime(&mut transport).expect("reconnect");
1626        assert_eq!(client.realtime_state(), RealtimeState::Connected);
1627        assert_eq!(transport.connects, 2);
1628    }
1629
1630    #[test]
1631    fn off_policy_uses_http_and_refuses_an_explicit_connect() {
1632        let mut client = client();
1633        client
1634            .set_realtime_policy(RealtimePolicy::Off)
1635            .expect("set policy");
1636        let mut transport = CountingRealtimeTransport::default();
1637        assert!(client.connect_realtime(&mut transport).is_err());
1638        assert_eq!(client.realtime_state(), RealtimeState::Disabled);
1639        assert!(matches!(
1640            client.sync(&mut transport),
1641            SyncOutcome::Failed { .. }
1642        ));
1643        assert_eq!(transport.sync_calls, 1);
1644        assert_eq!(transport.realtime_sync_calls, 0);
1645        let snapshot = client
1646            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1647            .expect("diagnostics");
1648        assert_eq!(snapshot.host.realtime, RealtimeState::Disabled);
1649        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Off);
1650    }
1651
1652    #[test]
1653    fn optional_policy_keeps_the_http_round() {
1654        let mut client = client();
1655        let mut transport = CountingRealtimeTransport::default();
1656        assert!(matches!(
1657            client.sync(&mut transport),
1658            SyncOutcome::Failed { .. }
1659        ));
1660        assert_eq!(transport.sync_calls, 1);
1661        assert_eq!(transport.realtime_sync_calls, 0);
1662        let snapshot = client
1663            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1664            .expect("diagnostics");
1665        assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
1666        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Optional);
1667    }
1668
1669    #[test]
1670    fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
1671        let mut client = client();
1672        client
1673            .subscribe(
1674                "repair-tasks".to_owned(),
1675                "tasks".to_owned(),
1676                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1677                None,
1678            )
1679            .expect("subscribe");
1680        {
1681            let sub = client
1682                .subs
1683                .iter_mut()
1684                .find(|sub| sub.id == "repair-tasks")
1685                .expect("subscription");
1686            sub.cursor = 42;
1687            sub.synced_once = true;
1688            let persisted = sub.clone();
1689            client.persist_sub(&persisted).unwrap();
1690        }
1691        for table in ["_syncular_base_tasks", "tasks"] {
1692            client
1693                .conn
1694                .execute(
1695                    &format!(
1696                        "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
1697                    ),
1698                    rusqlite::params!["server-row", "p1"],
1699                )
1700                .expect("seed server row");
1701        }
1702        let pending = client
1703            .mutate(vec![Mutation::Upsert {
1704                table: "tasks".to_owned(),
1705                values: Map::from_iter([
1706                    ("id".to_owned(), Value::from("offline-row")),
1707                    ("project_id".to_owned(), Value::from("p1")),
1708                ]),
1709                base_version: None,
1710            }])
1711            .expect("queue offline work");
1712        client.drain_change_batches();
1713        client.drain_sync_intents();
1714
1715        assert_eq!(
1716            client
1717                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1718                    rebootstrap_id: "support-case-001".to_owned(),
1719                })
1720                .expect("rebootstrap"),
1721            LocalDataRebootstrapResult {
1722                already_applied: false,
1723                retained_commits: 1,
1724                reset_subscriptions: 1,
1725            }
1726        );
1727        let visible_ids = client
1728            .conn
1729            .prepare("SELECT id FROM tasks ORDER BY id")
1730            .expect("prepare visible ids")
1731            .query_map([], |row| row.get::<_, String>(0))
1732            .expect("query visible ids")
1733            .collect::<Result<Vec<_>, _>>()
1734            .expect("collect visible ids");
1735        assert_eq!(visible_ids, vec!["offline-row"]);
1736        assert_eq!(client.pending_commit_ids(), vec![pending]);
1737        assert_eq!(
1738            client
1739                .subscription_state("repair-tasks")
1740                .expect("subscription")
1741                .cursor,
1742            -1
1743        );
1744        assert!(client.upgrading());
1745        assert!(client.sync_needed());
1746        assert!(matches!(
1747            client.drain_sync_intents().as_slice(),
1748            [SyncIntent::Interactive]
1749        ));
1750        assert_eq!(client.drain_change_batches().len(), 1);
1751
1752        assert_eq!(
1753            client
1754                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1755                    rebootstrap_id: "support-case-001".to_owned(),
1756                })
1757                .expect("idempotent retry"),
1758            LocalDataRebootstrapResult {
1759                already_applied: true,
1760                retained_commits: 1,
1761                reset_subscriptions: 1,
1762            }
1763        );
1764        assert!(client.drain_change_batches().is_empty());
1765    }
1766
1767    #[test]
1768    fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
1769        let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
1770        assert_eq!(
1771            decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
1772            (3, 4)
1773        );
1774        assert_eq!(
1775            decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
1776            (0, 0)
1777        );
1778        for malformed in [
1779            "",
1780            "{}",
1781            r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
1782            r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
1783            r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
1784        ] {
1785            assert_eq!(
1786                decode_local_rebootstrap_receipt(malformed)
1787                    .expect_err("malformed receipt must fail"),
1788                "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1789            );
1790        }
1791    }
1792
1793    #[test]
1794    fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
1795        let path = std::env::temp_dir().join(format!(
1796            "syncular-rebootstrap-receipt-{}.db",
1797            uuid::Uuid::new_v4()
1798        ));
1799        let schema = json!({
1800            "version": 1,
1801            "tables": [{
1802                "name": "tasks",
1803                "primaryKey": "id",
1804                "columns": [
1805                    { "name": "id", "type": "string", "nullable": false },
1806                    { "name": "project_id", "type": "string", "nullable": false }
1807                ],
1808                "scopes": [{ "pattern": "project:{project_id}" }]
1809            }]
1810        });
1811        let path_string = path.to_str().expect("UTF-8 temp path");
1812
1813        {
1814            let mut first = SyncClient::open_path(
1815                "repair-restart-client".to_owned(),
1816                &schema,
1817                ClientLimits::default(),
1818                path_string,
1819            )
1820            .expect("first open");
1821            first
1822                .subscribe(
1823                    "repair-tasks".to_owned(),
1824                    "tasks".to_owned(),
1825                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1826                    None,
1827                )
1828                .expect("subscribe");
1829            first
1830                .mutate(vec![Mutation::Upsert {
1831                    table: "tasks".to_owned(),
1832                    values: Map::from_iter([
1833                        ("id".to_owned(), Value::from("offline-row")),
1834                        ("project_id".to_owned(), Value::from("p1")),
1835                    ]),
1836                    base_version: None,
1837                }])
1838                .expect("queue offline work");
1839            assert_eq!(
1840                first
1841                    .rebootstrap_local_data(&LocalDataRebootstrapInput {
1842                        rebootstrap_id: "restart-receipt".to_owned(),
1843                    })
1844                    .expect("first rebootstrap"),
1845                LocalDataRebootstrapResult {
1846                    already_applied: false,
1847                    retained_commits: 1,
1848                    reset_subscriptions: 1,
1849                }
1850            );
1851        }
1852
1853        let mut reopened = SyncClient::open_path(
1854            "repair-restart-client".to_owned(),
1855            &schema,
1856            ClientLimits::default(),
1857            path_string,
1858        )
1859        .expect("reopen");
1860        reopened.drain_change_batches();
1861        reopened.drain_sync_intents();
1862        assert_eq!(
1863            reopened
1864                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1865                    rebootstrap_id: "restart-receipt".to_owned(),
1866                })
1867                .expect("receipt replay"),
1868            LocalDataRebootstrapResult {
1869                already_applied: true,
1870                retained_commits: 1,
1871                reset_subscriptions: 1,
1872            }
1873        );
1874        assert!(reopened.drain_change_batches().is_empty());
1875        assert!(reopened.drain_sync_intents().is_empty());
1876        drop(reopened);
1877        std::fs::remove_file(path).expect("remove temp database");
1878    }
1879
1880    #[test]
1881    fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
1882        let mut malformed = client();
1883        malformed
1884            .subscribe(
1885                "repair-tasks".to_owned(),
1886                "tasks".to_owned(),
1887                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1888                None,
1889            )
1890            .expect("subscribe");
1891        malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
1892        malformed.drain_change_batches();
1893        malformed.drain_sync_intents();
1894        let malformed_error = malformed
1895            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1896                rebootstrap_id: "malformed".to_owned(),
1897            })
1898            .expect_err("malformed receipt must fail closed");
1899        assert_eq!(
1900            malformed_error,
1901            "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1902        );
1903        assert!(!malformed.upgrading());
1904        assert_eq!(
1905            malformed
1906                .subscription_state("repair-tasks")
1907                .expect("unchanged subscription")
1908                .cursor,
1909            -1
1910        );
1911        assert!(malformed.drain_change_batches().is_empty());
1912        assert!(malformed.drain_sync_intents().is_empty());
1913
1914        let mut unreadable = client();
1915        unreadable
1916            .conn
1917            .execute(
1918                "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
1919                rusqlite::params!["server-row", "p1"],
1920            )
1921            .expect("seed visible row");
1922        unreadable
1923            .conn
1924            .execute("DROP TABLE _syncular_meta", [])
1925            .expect("break marker storage");
1926        let unreadable_error = unreadable
1927            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1928                rebootstrap_id: "unreadable".to_owned(),
1929            })
1930            .expect_err("unreadable marker storage must fail closed");
1931        assert_eq!(
1932            unreadable_error,
1933            "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
1934        );
1935        let visible_rows = unreadable
1936            .conn
1937            .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
1938            .expect("visible projection remains");
1939        assert_eq!(visible_rows, 1);
1940        assert!(!unreadable.upgrading());
1941        assert!(unreadable.drain_change_batches().is_empty());
1942        assert!(unreadable.drain_sync_intents().is_empty());
1943    }
1944
1945    #[test]
1946    fn local_rebootstrap_cannot_bypass_schema_floor() {
1947        let mut client = client();
1948        client.set_schema_floor(Some(SchemaFloor {
1949            required_schema_version: Some(2),
1950            latest_schema_version: Some(2),
1951        }));
1952        let error = client
1953            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1954                rebootstrap_id: "blocked-floor".to_owned(),
1955            })
1956            .expect_err("schema floor must block repair");
1957        assert!(error.contains("cannot bypass an active schema-floor stop"));
1958    }
1959
1960    #[test]
1961    fn diego_applied_sparse_moves_stay_visible_until_pull_image_arrives() {
1962        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
1963            "columns":[{"name":"id","type":"string","nullable":false},
1964                {"name":"project_id","type":"string","nullable":false},
1965                {"name":"starts_at_ms","type":"integer","nullable":false},
1966                {"name":"version","type":"integer","nullable":false}],
1967            "scopes":[{"pattern":"project:{project_id}"}]}]});
1968        let mut client =
1969            SyncClient::new("diego-ack-gap".into(), &schema, ClientLimits::default()).unwrap();
1970        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1971        client
1972            .write_base_row(
1973                "tasks",
1974                &vec![
1975                    Some(ColumnValue::String("t1".into())),
1976                    Some(ColumnValue::String("p1".into())),
1977                    Some(ColumnValue::Integer(100)),
1978                    Some(ColumnValue::Integer(1)),
1979                ],
1980                1,
1981            )
1982            .unwrap();
1983        client.rebuild_overlay();
1984        let first = client
1985            .patch(
1986                "tasks",
1987                "t1",
1988                Map::from_iter([
1989                    ("starts_at_ms".into(), json!(200)),
1990                    ("version".into(), json!(2)),
1991                ]),
1992                None,
1993            )
1994            .unwrap();
1995        let (_, first_request) = client.build_request(false);
1996        // This second edit happens while the first request is in flight.
1997        let second = client
1998            .patch(
1999                "tasks",
2000                "t1",
2001                Map::from_iter([
2002                    ("starts_at_ms".into(), json!(300)),
2003                    ("version".into(), json!(3)),
2004                ]),
2005                None,
2006            )
2007            .unwrap();
2008        let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2009        for (id, seq, meta) in [
2010            (first, 2, first_request),
2011            (second, 3, client.build_request(false).1),
2012        ] {
2013            let response = Message {
2014                wire_version: WIRE_VERSION,
2015                msg_kind: MsgKind::Response,
2016                frames: vec![
2017                    Frame::RespHeader {
2018                        required_schema_version: None,
2019                        latest_schema_version: None,
2020                        log_epoch: Some("epoch-1".into()),
2021                        reset_required: Some(false),
2022                    },
2023                    Frame::PushResult {
2024                        client_commit_id: id,
2025                        status: PushStatus::Applied,
2026                        commit_seq: Some(seq),
2027                        results: vec![OpResult::Applied { op_index: 0 }],
2028                    },
2029                ],
2030            };
2031            assert!(matches!(
2032                client.process_response(&mut transport, response, &meta),
2033                SyncOutcome::Ok(_)
2034            ));
2035            let row = &client
2036                .query("SELECT starts_at_ms, version FROM tasks WHERE id='t1'", &[])
2037                .unwrap()[0];
2038            assert_eq!(
2039                row["starts_at_ms"],
2040                json!(300),
2041                "an applied ACK without a pull image must not expose the old base"
2042            );
2043            assert_eq!(
2044                row["version"],
2045                json!(3),
2046                "the queued domain version must remain monotonic"
2047            );
2048        }
2049    }
2050
2051    #[test]
2052    fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
2053        let mut client = client();
2054        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2055        const COMMIT_COUNT: usize = 32;
2056
2057        for index in 0..COMMIT_COUNT {
2058            client
2059                .mutate(vec![Mutation::Upsert {
2060                    table: "tasks".to_owned(),
2061                    values: Map::from_iter([
2062                        ("id".to_owned(), Value::from(format!("task-{index}"))),
2063                        ("project_id".to_owned(), Value::from("project-1")),
2064                    ]),
2065                    base_version: None,
2066                }])
2067                .expect("queue commit");
2068        }
2069
2070        let (_, request_meta) = client.build_request(false);
2071        assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
2072        let mut frames = vec![Frame::RespHeader {
2073            required_schema_version: None,
2074            latest_schema_version: None,
2075            log_epoch: Some("epoch-1".to_owned()),
2076            reset_required: Some(false),
2077        }];
2078        frames.extend(request_meta.pushed_ids.iter().enumerate().map(
2079            |(index, client_commit_id)| Frame::PushResult {
2080                client_commit_id: client_commit_id.clone(),
2081                status: PushStatus::Applied,
2082                commit_seq: Some(index as i64 + 1),
2083                results: vec![OpResult::Applied { op_index: 0 }],
2084            },
2085        ));
2086        let response = Message {
2087            wire_version: WIRE_VERSION,
2088            msg_kind: MsgKind::Response,
2089            frames,
2090        };
2091        let mut transport =
2092            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
2093
2094        client.overlay_rebuild_count.set(0);
2095        client.outcome_prune_count.set(0);
2096        let outcome = client.process_response(&mut transport, response, &request_meta);
2097        assert!(matches!(outcome, SyncOutcome::Ok(_)));
2098        assert!(client.pending_commit_ids().is_empty());
2099        assert_eq!(
2100            client.overlay_rebuild_count.get(),
2101            1,
2102            "one response must reconcile its acknowledged commits with one overlay rebuild"
2103        );
2104        assert_eq!(
2105            client.outcome_prune_count.get(),
2106            1,
2107            "one response must enforce outcome retention once"
2108        );
2109    }
2110
2111    #[test]
2112    fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
2113        let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
2114            "columns": [
2115                {"name":"id", "type":"string", "nullable":false},
2116                {"name":"project_id", "type":"string", "nullable":false},
2117                {"name":"title", "type":"string", "nullable":false}
2118            ], "scopes": [{"pattern":"project:{project_id}"}],
2119            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
2120            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
2121        }] });
2122        let mut incremental =
2123            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
2124        let mut reference =
2125            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
2126        let tables = vec!["tasks".to_owned()];
2127        for index in 0..32 {
2128            let id = format!("task-{}", index % 8);
2129            let values = Map::from_iter([
2130                ("id".to_owned(), Value::from(id.clone())),
2131                (
2132                    "project_id".to_owned(),
2133                    Value::from(format!("p{}", index % 2)),
2134                ),
2135                ("title".to_owned(), Value::from(format!("needle{index}"))),
2136            ]);
2137            let payload = encode_row_json(
2138                incremental.schema.table("tasks").unwrap(),
2139                &id,
2140                &values,
2141                &incremental.encryption,
2142            )
2143            .unwrap();
2144            let change = ssp2::model::Change {
2145                table_index: 0,
2146                row_id: id,
2147                op: if index % 5 == 0 {
2148                    Op::Delete
2149                } else {
2150                    Op::Upsert
2151                },
2152                row_version: Some(index + 1),
2153                scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
2154                row: Some(payload),
2155            };
2156            reference.overlay_dirty.set(true);
2157            for instance in [&mut incremental, &mut reference] {
2158                instance.begin_observation("mirror_test").unwrap();
2159                let mut batch = ChangeAccumulator::default();
2160                instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
2161                instance
2162                    .apply_commit_changes(&tables, std::slice::from_ref(&change))
2163                    .unwrap();
2164                instance.rebuild_overlay_if_dirty();
2165                instance.finish_observation("mirror_test", batch).unwrap();
2166            }
2167            for sql in [
2168                "SELECT * FROM tasks ORDER BY id",
2169                "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
2170            ] {
2171                assert_eq!(
2172                    incremental.query(sql, &[]).unwrap(),
2173                    reference.query(sql, &[]).unwrap()
2174                );
2175            }
2176            assert_eq!(incremental.local_revision(), reference.local_revision());
2177            assert_eq!(
2178                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2179                serde_json::to_value(reference.drain_change_batches()).unwrap()
2180            );
2181        }
2182        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
2183        assert_eq!(reference.overlay_rebuild_count.get(), 32);
2184    }
2185
2186    #[test]
2187    fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
2188        let cases = [
2189            (
2190                "string",
2191                vec![
2192                    json!("1"),
2193                    json!("01"),
2194                    json!(""),
2195                    json!("nul\u{0}id"),
2196                    json!("é"),
2197                ],
2198            ),
2199            (
2200                "integer",
2201                vec![
2202                    json!(i64::MIN),
2203                    json!(-1),
2204                    json!(0),
2205                    json!(1),
2206                    json!(i64::MAX),
2207                ],
2208            ),
2209            ("boolean", vec![json!(false), json!(true)]),
2210            ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
2211        ];
2212        for (kind, ids) in cases {
2213            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2214                "columns":[{"name":"id","type":kind,"nullable":false},
2215                    {"name":"project_id","type":"string","nullable":false}],
2216                "scopes":[{"pattern":"project:{project_id}"}]}]});
2217            let mut client =
2218                SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
2219            for (index, id) in ids.iter().enumerate() {
2220                let table = client.schema.table("tasks").unwrap();
2221                client
2222                    .write_base_row(
2223                        "tasks",
2224                        &vec![
2225                            json_to_column_value(&table.columns[0], Some(id)).unwrap(),
2226                            Some(ColumnValue::String(format!("p{index}"))),
2227                        ],
2228                        1,
2229                    )
2230                    .unwrap();
2231            }
2232            client.rebuild_overlay_if_dirty();
2233            for base in [true, false] {
2234                let full = if base {
2235                    base_table("tasks")
2236                } else {
2237                    visible_table("tasks")
2238                };
2239                let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
2240                for row_id in [
2241                    "1",
2242                    "01",
2243                    "1.0",
2244                    "1.0000000000000002",
2245                    "+1",
2246                    "1e0",
2247                    " 1",
2248                    "-1",
2249                    "0",
2250                    "9223372036854775807",
2251                    "-9223372036854775808",
2252                    "true",
2253                    "false",
2254                    "",
2255                    "nul\u{0}id",
2256                    "é",
2257                    "null",
2258                    "{\"a\":1}",
2259                ] {
2260                    let old_sql =
2261                        format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
2262                    let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
2263                    let old = client
2264                        .conn
2265                        .prepare(&old_sql)
2266                        .unwrap()
2267                        .query_map([row_id], |row| row.get::<_, String>(0))
2268                        .unwrap()
2269                        .collect::<Result<Vec<_>, _>>()
2270                        .unwrap();
2271                    let new = client
2272                        .conn
2273                        .prepare_cached(&new_sql)
2274                        .unwrap()
2275                        .query_map([row_id], |row| row.get::<_, String>(0))
2276                        .unwrap()
2277                        .collect::<Result<Vec<_>, _>>()
2278                        .unwrap();
2279                    assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
2280                    let mut batch = ChangeAccumulator::default();
2281                    assert_eq!(
2282                        client.record_row_scopes(&mut batch, "tasks", row_id, base),
2283                        !old.is_empty()
2284                    );
2285                    let expected = old.first().map(|scope| {
2286                        BTreeMap::from([(
2287                            "tasks".to_owned(),
2288                            Some(BTreeSet::from([format!("project:{scope}")])),
2289                        )])
2290                    });
2291                    assert_eq!(batch.tables, expected.unwrap_or_default());
2292                    client
2293                        .conn
2294                        .execute_batch("SAVEPOINT lookup_delete")
2295                        .unwrap();
2296                    if base {
2297                        client.delete_base_row("tasks", row_id).unwrap();
2298                    } else {
2299                        client.apply_outbox_ops(&[OutboxOp {
2300                            upsert: false,
2301                            table: "tasks".into(),
2302                            row_id: row_id.into(),
2303                            base_version: None,
2304                            values: None,
2305                        }]);
2306                    }
2307                    let remaining: i64 = client
2308                        .conn
2309                        .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
2310                            row.get(0)
2311                        })
2312                        .unwrap();
2313                    assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
2314                    client
2315                        .conn
2316                        .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
2317                        .unwrap();
2318                    client.overlay_dirty.set(false);
2319                }
2320                for (predicate, expected) in [
2321                    ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
2322                    (predicate, "SEARCH"),
2323                ] {
2324                    let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
2325                    let details = client
2326                        .conn
2327                        .prepare(&sql)
2328                        .unwrap()
2329                        .query_map(["1"], |row| row.get::<_, String>(3))
2330                        .unwrap()
2331                        .collect::<Result<Vec<_>, _>>()
2332                        .unwrap();
2333                    assert!(
2334                        details.iter().any(|detail| detail.contains(expected)),
2335                        "{kind}: {details:?}"
2336                    );
2337                }
2338            }
2339        }
2340    }
2341
2342    #[test]
2343    fn primary_keys_reject_every_ineligible_column_type() {
2344        // §2.4: a `rowId` is a string, so a primary key type whose string form
2345        // differs per renderer, or cannot be reproduced by local storage
2346        // comparison, is a schema error rather than a silent lookup miss.
2347        for ty in ["float", "bytes", "crdt", "blob_ref"] {
2348            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2349                "columns":[{"name":"id","type":ty,"nullable":false},
2350                    {"name":"project_id","type":"string","nullable":false}],
2351                "scopes":[{"pattern":"project:{project_id}"}]}]});
2352            let error =
2353                match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
2354                    Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
2355                    Err(error) => error,
2356                };
2357            assert!(
2358                error.contains("has an ineligible column type (§2.4)"),
2359                "{ty}: {error}"
2360            );
2361        }
2362        for ty in ["string", "integer", "boolean", "json"] {
2363            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2364                "columns":[{"name":"id","type":ty,"nullable":false},
2365                    {"name":"project_id","type":"string","nullable":false}],
2366                "scopes":[{"pattern":"project:{project_id}"}]}]});
2367            SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
2368                .unwrap_or_else(|error| panic!("{ty}: {error}"));
2369        }
2370    }
2371
2372    #[test]
2373    fn pending_row_reconciliation_matches_full_fifo_replay() {
2374        for unique in [false, true] {
2375            let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
2376                "name": name, "primaryKey": "id", "columns": [
2377                    {"name":"id", "type":"string", "nullable":false},
2378                    {"name":"project_id", "type":"string", "nullable":false},
2379                    {"name":"title", "type":"string", "nullable":false}],
2380                "scopes": [{"pattern":"project:{project_id}"}],
2381                "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
2382                "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
2383            })))});
2384            let mut incremental =
2385                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
2386            let mut reference =
2387                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
2388            let tables = vec!["tasks".to_owned(), "notes".to_owned()];
2389            for instance in [&mut incremental, &mut reference] {
2390                for table in &tables {
2391                    for index in 0..12 {
2392                        instance
2393                            .write_base_row(
2394                                table,
2395                                &vec![
2396                                    Some(ColumnValue::String(format!("row{index}"))),
2397                                    Some(ColumnValue::String("p0".into())),
2398                                    Some(ColumnValue::String(format!("base{index}"))),
2399                                ],
2400                                1,
2401                            )
2402                            .unwrap();
2403                    }
2404                }
2405                instance.rebuild_overlay_if_dirty();
2406            }
2407            for index in 0..80 {
2408                let mutations = (0..3)
2409                    .map(|offset| {
2410                        let table = tables[(index + offset) % 2].clone();
2411                        let id = format!("row{}", (index + offset) % 16);
2412                        if (index + offset) % 7 == 0 {
2413                            Mutation::Delete {
2414                                table,
2415                                row_id: id,
2416                                base_version: None,
2417                            }
2418                        } else {
2419                            Mutation::Upsert {
2420                                table,
2421                                values: Map::from_iter([
2422                                    ("id".into(), Value::from(id)),
2423                                    ("project_id".into(), Value::from(format!("p{}", index % 3))),
2424                                    (
2425                                        "title".into(),
2426                                        Value::from(format!("needle{}", (index + offset) % 16)),
2427                                    ),
2428                                ]),
2429                                base_version: None,
2430                            }
2431                        }
2432                    })
2433                    .collect::<Vec<_>>();
2434                for instance in [&mut incremental, &mut reference] {
2435                    instance.mutate(mutations.clone()).unwrap();
2436                }
2437                // Most frames touch only the table without unique constraints.
2438                // Mixed frames must retain full replay for both tables.
2439                let changes = (0..3)
2440                    .map(|offset| {
2441                        let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
2442                        let id = format!("row{}", (index + offset) % 16);
2443                        let values = Map::from_iter([
2444                            ("id".into(), Value::from(id.clone())),
2445                            (
2446                                "project_id".into(),
2447                                Value::from(format!("p{}", (index + 1) % 3)),
2448                            ),
2449                            (
2450                                "title".into(),
2451                                Value::from(format!("remote{index}_{offset}")),
2452                            ),
2453                        ]);
2454                        ssp2::model::Change {
2455                            table_index: table_index as u16,
2456                            row_id: id.clone(),
2457                            op: if (index + offset) % 5 == 0 {
2458                                Op::Delete
2459                            } else {
2460                                Op::Upsert
2461                            },
2462                            row_version: Some(index as i64 + 2),
2463                            scopes: vec![],
2464                            row: Some(
2465                                encode_row_json(
2466                                    incremental.schema.table(&tables[table_index]).unwrap(),
2467                                    &id,
2468                                    &values,
2469                                    &incremental.encryption,
2470                                )
2471                                .unwrap(),
2472                            ),
2473                        }
2474                    })
2475                    .collect::<Vec<_>>();
2476                reference.overlay_dirty.set(true);
2477                incremental
2478                    .apply_commit_frame(&tables, &changes, None)
2479                    .unwrap();
2480                reference
2481                    .apply_commit_frame(&tables, &changes, None)
2482                    .unwrap();
2483                for table in &tables {
2484                    for sql in [format!("SELECT * FROM {table} ORDER BY id"),
2485                        format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
2486                        format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
2487                        format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
2488                        assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
2489                    }
2490                }
2491                assert_eq!(incremental.local_revision(), reference.local_revision());
2492                assert_eq!(
2493                    serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2494                    serde_json::to_value(reference.drain_change_batches()).unwrap()
2495                );
2496            }
2497            assert_eq!(
2498                incremental.overlay_rebuild_count.get(),
2499                if unique { 81 } else { 21 }
2500            );
2501            assert_eq!(reference.overlay_rebuild_count.get(), 81);
2502        }
2503    }
2504
2505    #[test]
2506    fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
2507        let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
2508            "columns":[{"name":"id", "type":"string", "nullable":false},
2509                {"name":"title", "type":"string", "nullable":false}],
2510            "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
2511        let mut client =
2512            SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
2513        for (id, title) in [("a", "original"), ("b", "occupied")] {
2514            client
2515                .write_base_row(
2516                    "tasks",
2517                    &vec![
2518                        Some(ColumnValue::String(id.into())),
2519                        Some(ColumnValue::String(title.into())),
2520                    ],
2521                    1,
2522                )
2523                .unwrap();
2524        }
2525        client.rebuild_overlay_if_dirty();
2526        let pending = client
2527            .mutate(vec![Mutation::Upsert {
2528                table: "tasks".into(),
2529                values: Map::from_iter([
2530                    ("id".into(), Value::from("a")),
2531                    ("title".into(), Value::from("available")),
2532                ]),
2533                base_version: None,
2534            }])
2535            .unwrap();
2536        assert_eq!(
2537            client
2538                .conn
2539                .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2540                    .get::<_, String>(
2541                    0
2542                ))
2543                .unwrap(),
2544            "available"
2545        );
2546        for (title, expected) in [("available", "original"), ("occupied", "available")] {
2547            let change = ssp2::model::Change {
2548                table_index: 0,
2549                row_id: "b".into(),
2550                op: Op::Upsert,
2551                row_version: Some(2),
2552                scopes: vec![],
2553                row: Some(
2554                    encode_row_json(
2555                        client.schema.table("tasks").unwrap(),
2556                        "b",
2557                        &Map::from_iter([
2558                            ("id".into(), Value::from("b")),
2559                            ("title".into(), Value::from(title)),
2560                        ]),
2561                        &client.encryption,
2562                    )
2563                    .unwrap(),
2564                ),
2565            };
2566            client
2567                .apply_commit_frame(&["tasks".into()], &[change], None)
2568                .unwrap();
2569            assert_eq!(
2570                client
2571                    .conn
2572                    .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2573                        .get::<_, String>(
2574                        0
2575                    ))
2576                    .unwrap(),
2577                expected
2578            );
2579            assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
2580        }
2581        assert_eq!(client.overlay_rebuild_count.get(), 3);
2582    }
2583
2584    fn measure_scoped_overlay(unchanged_rows: i64, rounds: i64) -> Vec<u128> {
2585        let schema = json!({ "version": 1, "tables": [
2586            { "name":"tasks", "primaryKey":"id", "columns":[
2587                {"name":"id","type":"string","nullable":false},
2588                {"name":"project_id","type":"string","nullable":false}],
2589                "scopes":[{"pattern":"project:{project_id}"}] },
2590            { "name":"catalogue", "primaryKey":"id", "columns":[
2591                {"name":"id","type":"string","nullable":false},
2592                {"name":"project_id","type":"string","nullable":false},
2593                {"name":"title","type":"string","nullable":false}],
2594                "scopes":[{"pattern":"project:{project_id}"}],
2595                "ftsIndexes":[{"name":"catalogue_fts","columns":["title"],"tokenize":"unicode61"}] }
2596        ] });
2597        let mut client =
2598            SyncClient::new("overlay-scope".into(), &schema, ClientLimits::default()).unwrap();
2599        if unchanged_rows > 0 {
2600            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();
2601        }
2602        client.rebuild_overlay();
2603        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();
2604        let mut elapsed = Vec::new();
2605        for round in 1..=rounds {
2606            let pending = client
2607                .mutate(vec![Mutation::Upsert {
2608                    table: "tasks".into(),
2609                    values: Map::from_iter([
2610                        ("id".into(), json!("t1")),
2611                        ("project_id".into(), json!("p1")),
2612                    ]),
2613                    base_version: None,
2614                }])
2615                .unwrap();
2616            let started = std::time::Instant::now();
2617            client
2618                .write_base_row(
2619                    "tasks",
2620                    &vec![
2621                        Some(ColumnValue::String("t1".into())),
2622                        Some(ColumnValue::String("p1".into())),
2623                    ],
2624                    round,
2625                )
2626                .unwrap();
2627            let mut report = SyncReport::default();
2628            client
2629                .handle_push_results(
2630                    &[Frame::PushResult {
2631                        client_commit_id: pending.clone(),
2632                        status: PushStatus::Applied,
2633                        commit_seq: Some(round),
2634                        results: vec![OpResult::Applied { op_index: 0 }],
2635                    }],
2636                    &HashSet::from([pending.as_str()]),
2637                    &HashMap::new(),
2638                    &mut report,
2639                    Some(&pending),
2640                )
2641                .unwrap();
2642            client.rebuild_overlay_if_dirty();
2643            elapsed.push(started.elapsed().as_nanos());
2644        }
2645        assert_eq!(
2646            client
2647                .conn
2648                .query_row("SELECT count(*) FROM audit", [], |r| r.get::<_, i64>(0))
2649                .unwrap(),
2650            0,
2651            "unchanged catalogue must never be copied"
2652        );
2653        assert_eq!(
2654            client
2655                .conn
2656                .query_row(
2657                    "SELECT count(*) FROM catalogue_fts WHERE catalogue_fts MATCH 'needle'",
2658                    [],
2659                    |r| r.get::<_, i64>(0)
2660                )
2661                .unwrap(),
2662            unchanged_rows
2663        );
2664        assert_eq!(
2665            client.query("SELECT * FROM tasks", &[]).unwrap()[0]["_syncular_version"],
2666            json!(rounds)
2667        );
2668        elapsed
2669    }
2670
2671    #[test]
2672    fn changed_overlay_leaves_large_unchanged_table_and_fts_untouched() {
2673        let elapsed = measure_scoped_overlay(100_000, 1);
2674        eprintln!(
2675            "table-scoped overlay with 100k unchanged rows: {} ns",
2676            elapsed[0]
2677        );
2678    }
2679
2680    #[test]
2681    #[ignore = "isolated performance measurement; run with the shared heavy-check lock"]
2682    fn benchmark_scoped_overlay() {
2683        for rows in [0, 100_000, 200_000] {
2684            let mut elapsed = measure_scoped_overlay(rows, 30);
2685            elapsed.sort_unstable();
2686            eprintln!(
2687                "{}",
2688                json!({"unchangedRows": rows, "rounds": elapsed.len(),
2689                "medianNs": elapsed[elapsed.len()/2], "minNs": elapsed[0],
2690                "maxNs": elapsed[elapsed.len()-1], "unchangedDeletes": 0})
2691            );
2692        }
2693    }
2694
2695    #[test]
2696    fn released_context_discards_in_flight_ack_and_server_rows() {
2697        for barrier in ["preflight", "purge", "subscription"] {
2698            let mut client = client();
2699            client.set_meta(LOG_EPOCH_KEY, "epoch");
2700            client
2701                .subscribe(
2702                    "tasks".into(),
2703                    "tasks".into(),
2704                    vec![("project_id".into(), vec!["p1".into()])],
2705                    None,
2706                )
2707                .unwrap();
2708            let commit = client
2709                .mutate(vec![Mutation::Upsert {
2710                    table: "tasks".into(),
2711                    values: Map::from_iter([
2712                        ("id".into(), json!("local")),
2713                        ("project_id".into(), json!("p1")),
2714                    ]),
2715                    base_version: None,
2716                }])
2717                .unwrap();
2718            let prepared = client.prepare_sync_round(false).unwrap();
2719            client.set_transport_enabled(&mut CountingRealtimeTransport::default(), false);
2720            let payload = encode_row_json(
2721                &client.schema.tables[0],
2722                "server",
2723                &Map::from_iter([
2724                    ("id".into(), json!("server")),
2725                    ("project_id".into(), json!("p1")),
2726                ]),
2727                &client.encryption,
2728            )
2729            .unwrap();
2730            let completed = crate::CompletedSyncRound {
2731                prepared,
2732                exchange: crate::round::ExchangeResult::Reply {
2733                    transport_failed: false,
2734                    downloads: Default::default(),
2735                    response: Ok(Message {
2736                        wire_version: WIRE_VERSION,
2737                        msg_kind: MsgKind::Response,
2738                        frames: vec![
2739                            Frame::RespHeader {
2740                                required_schema_version: None,
2741                                latest_schema_version: None,
2742                                log_epoch: Some("epoch".into()),
2743                                reset_required: Some(false),
2744                            },
2745                            Frame::PushResult {
2746                                client_commit_id: commit.clone(),
2747                                status: PushStatus::Applied,
2748                                commit_seq: Some(1),
2749                                results: vec![OpResult::Applied { op_index: 0 }],
2750                            },
2751                            Frame::SubStart {
2752                                id: "tasks".into(),
2753                                status: SubStatus::Active,
2754                                reason_code: String::new(),
2755                                effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
2756                                bootstrap: true,
2757                            },
2758                            Frame::Commit {
2759                                commit_seq: 1,
2760                                created_at_ms: 0,
2761                                actor_id: "actor".into(),
2762                                tables: vec!["tasks".into()],
2763                                changes: vec![ssp2::model::Change {
2764                                    table_index: 0,
2765                                    row_id: "server".into(),
2766                                    op: Op::Upsert,
2767                                    row_version: Some(1),
2768                                    scopes: vec![("project_id".into(), "p1".into())],
2769                                    row: Some(payload),
2770                                }],
2771                            },
2772                            Frame::SubEnd {
2773                                next_cursor: 1,
2774                                bootstrap_state: None,
2775                            },
2776                        ],
2777                    }),
2778                },
2779            };
2780            match barrier {
2781                "preflight" => client.begin_security_preflight(),
2782                "purge" => {
2783                    client
2784                        .purge_local_data(&LocalDataPurgeInput {
2785                            purge_id: "pending-round".into(),
2786                            targets: vec![LocalDataPurgeTarget {
2787                                table: "tasks".into(),
2788                                selectors: BTreeMap::from([(
2789                                    "project_id".into(),
2790                                    vec!["p1".into()],
2791                                )]),
2792                            }],
2793                        })
2794                        .unwrap();
2795                }
2796                "subscription" => {
2797                    client.unsubscribe("tasks");
2798                    client
2799                        .subscribe(
2800                            "tasks".into(),
2801                            "tasks".into(),
2802                            vec![("project_id".into(), vec!["p2".into()])],
2803                            None,
2804                        )
2805                        .unwrap();
2806                }
2807                _ => unreachable!(),
2808            }
2809            let pending_before_reply = client.pending_commit_ids();
2810            let revision = client.local_revision();
2811            let applied = client.apply_sync_round(completed);
2812            assert!(
2813                matches!(applied, crate::AppliedSyncRound::Complete { outcome: SyncOutcome::Failed { ref error_code, .. }, ref controls, .. } if error_code == "client.round_cancelled" && controls.is_empty())
2814            );
2815            assert_eq!(client.local_revision(), revision);
2816            assert!(client
2817                .conn
2818                .query_row(
2819                    "SELECT NOT EXISTS(SELECT 1 FROM tasks WHERE id='server')",
2820                    [],
2821                    |r| r.get::<_, bool>(0)
2822                )
2823                .unwrap());
2824            assert_eq!(client.pending_commit_ids(), pending_before_reply);
2825        }
2826    }
2827
2828    #[test]
2829    fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
2830        for failure in ["visible", "revision", "commit"] {
2831            let mut client = client();
2832            let id = client
2833                .mutate(vec![Mutation::Upsert {
2834                    table: "tasks".into(),
2835                    values: Map::from_iter([
2836                        ("id".into(), Value::from("t1")),
2837                        ("project_id".into(), Value::from("pending")),
2838                    ]),
2839                    base_version: None,
2840                }])
2841                .unwrap();
2842            client.drain_change_batches();
2843            let revision = client.local_revision();
2844            let before = client.query("SELECT * FROM tasks", &[]).unwrap();
2845            let trigger = match failure {
2846                "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
2847                "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"),
2848                _ => "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(),
2849            };
2850            client.conn.execute_batch(&trigger).unwrap();
2851            let change = ssp2::model::Change {
2852                table_index: 0,
2853                row_id: "t1".into(),
2854                op: Op::Upsert,
2855                row_version: Some(2),
2856                scopes: vec![],
2857                row: Some(
2858                    encode_row_json(
2859                        client.schema.table("tasks").unwrap(),
2860                        "t1",
2861                        &Map::from_iter([
2862                            ("id".into(), Value::from("t1")),
2863                            ("project_id".into(), Value::from("remote")),
2864                        ]),
2865                        &client.encryption,
2866                    )
2867                    .unwrap(),
2868                ),
2869            };
2870            assert!(client
2871                .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), None)
2872                .is_err());
2873            assert!(client.conn.is_autocommit());
2874            assert!(!client.overlay_dirty.get());
2875            assert_eq!(client.local_revision(), revision);
2876            assert!(client.drain_change_batches().is_empty());
2877            assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
2878            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
2879            assert!(client
2880                .query("SELECT * FROM _syncular_base_tasks", &[])
2881                .unwrap()
2882                .is_empty());
2883            client
2884                .conn
2885                .execute_batch("DROP TRIGGER fail_pending")
2886                .unwrap();
2887            client
2888                .apply_commit_frame(&["tasks".into()], &[change], None)
2889                .unwrap();
2890            assert_eq!(client.local_revision(), revision + 1);
2891            assert_eq!(client.pending_commit_ids(), vec![id]);
2892            // The overlay keeps the pending write visible; the row now carries
2893            // the server row's version it is layered on (§7.1).
2894            let mut expected = before[0].clone();
2895            expected.insert("_syncular_version".to_owned(), Value::from(2));
2896            assert_eq!(
2897                client.query("SELECT * FROM tasks", &[]).unwrap(),
2898                vec![expected]
2899            );
2900            assert_eq!(
2901                client
2902                    .conn
2903                    .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
2904                        .get::<_, String>(0))
2905                    .unwrap(),
2906                "remote"
2907            );
2908        }
2909    }
2910
2911    #[test]
2912    fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
2913        let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
2914            "columns": [
2915                {"name":"id", "type":"string", "nullable":false},
2916                {"name":"project_id", "type":"string", "nullable":false},
2917                {"name":"title", "type":"string", "nullable":false}],
2918            "scopes": [{"pattern":"project:{project_id}"}],
2919            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
2920            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
2921        let mut incremental =
2922            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2923        let mut reference =
2924            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2925        for instance in [&mut incremental, &mut reference] {
2926            for index in 0..16 {
2927                instance
2928                    .write_base_row(
2929                        "tasks",
2930                        &vec![
2931                            Some(ColumnValue::String(format!("task{index}"))),
2932                            Some(ColumnValue::String("p0".to_owned())),
2933                            Some(ColumnValue::String(format!("base{index}"))),
2934                        ],
2935                        1,
2936                    )
2937                    .unwrap();
2938            }
2939            instance.rebuild_overlay_if_dirty();
2940            instance.overlay_rebuild_count.set(0);
2941        }
2942        for index in 0..100 {
2943            let mutations = (0..1 + index % 4)
2944                .map(|offset| {
2945                    let id = format!("task{}", (index + offset) % 24);
2946                    if (index + offset) % 7 == 0 {
2947                        Mutation::Delete {
2948                            table: "tasks".to_owned(),
2949                            row_id: id,
2950                            base_version: None,
2951                        }
2952                    } else {
2953                        Mutation::Upsert {
2954                            table: "tasks".to_owned(),
2955                            values: Map::from_iter([
2956                                ("id".to_owned(), Value::from(id)),
2957                                (
2958                                    "project_id".to_owned(),
2959                                    Value::from(format!("p{}", index % 2)),
2960                                ),
2961                                (
2962                                    "title".to_owned(),
2963                                    Value::from(format!("needle{}", (index + offset) % 24)),
2964                                ),
2965                            ]),
2966                            base_version: None,
2967                        }
2968                    }
2969                })
2970                .collect::<Vec<_>>();
2971            reference.overlay_dirty.set(true);
2972            incremental.mutate(mutations.clone()).unwrap();
2973            reference.mutate(mutations).unwrap();
2974            for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
2975                "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
2976                assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
2977            }
2978            assert_eq!(incremental.local_revision(), reference.local_revision());
2979            assert_eq!(
2980                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2981                serde_json::to_value(reference.drain_change_batches()).unwrap()
2982            );
2983        }
2984        assert_eq!(incremental.outbox.len(), 100);
2985        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
2986        assert_eq!(reference.overlay_rebuild_count.get(), 100);
2987    }
2988
2989    #[test]
2990    fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
2991        for fail_at in ["outbox", "revision"] {
2992            let mut client = client();
2993            let values = Map::from_iter([
2994                ("id".to_owned(), Value::from("t1")),
2995                ("project_id".to_owned(), Value::from("p1")),
2996            ]);
2997            let mutation = Mutation::Upsert {
2998                table: "tasks".to_owned(),
2999                values,
3000                base_version: None,
3001            };
3002            let trigger = if fail_at == "outbox" {
3003                "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
3004            } else {
3005                format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
3006            };
3007            client.conn.execute_batch(&trigger).unwrap();
3008            let revision = client.local_revision();
3009            assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
3010            assert_eq!(client.local_revision(), revision);
3011            assert!(client.outbox.is_empty());
3012            assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
3013            assert_eq!(
3014                client
3015                    .conn
3016                    .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
3017                        .get::<_, i64>(0))
3018                    .unwrap(),
3019                0
3020            );
3021            assert!(client.drain_change_batches().is_empty());
3022            assert!(!client.overlay_dirty.get());
3023            client
3024                .conn
3025                .execute_batch("DROP TRIGGER fail_append")
3026                .unwrap();
3027            client.mutate(vec![mutation]).unwrap();
3028            assert_eq!(client.outbox.len(), 1);
3029            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
3030        }
3031    }
3032
3033    #[test]
3034    fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
3035        let mut client = client();
3036        let tables = vec!["tasks".to_owned(), "missing".to_owned()];
3037        let values = Map::from_iter([
3038            ("id".to_owned(), Value::from("t1")),
3039            ("project_id".to_owned(), Value::from("p1")),
3040        ]);
3041        let payload = encode_row_json(
3042            client.schema.table("tasks").unwrap(),
3043            "t1",
3044            &values,
3045            &client.encryption,
3046        )
3047        .unwrap();
3048        let first = ssp2::model::Change {
3049            table_index: 0,
3050            row_id: "t1".to_owned(),
3051            op: Op::Upsert,
3052            row_version: Some(1),
3053            scopes: vec![("project_id".to_owned(), "p1".to_owned())],
3054            row: Some(payload),
3055        };
3056        let mut invalid = first.clone();
3057        invalid.table_index = 1;
3058        let revision = client.local_revision();
3059        client.begin_observation("failed_mirror").unwrap();
3060        assert!(client
3061            .apply_commit_changes(&tables, &[first, invalid])
3062            .is_err());
3063        client.rollback_observation("failed_mirror");
3064        client.rebuild_overlay_if_dirty();
3065        assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
3066        assert_eq!(
3067            client
3068                .conn
3069                .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
3070                    .get::<_, i64>(
3071                    0
3072                ))
3073                .unwrap(),
3074            0
3075        );
3076        assert_eq!(client.local_revision(), revision);
3077        assert!(client.drain_change_batches().is_empty());
3078    }
3079
3080    #[test]
3081    fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
3082        for failure in ["journal", "revision", "commit", "later-frame"] {
3083            let mut client = client();
3084            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3085            for id in ["first", "second"] {
3086                client
3087                    .mutate(vec![Mutation::Upsert {
3088                        table: "tasks".to_owned(),
3089                        values: Map::from_iter([
3090                            ("id".to_owned(), Value::from(id)),
3091                            ("project_id".to_owned(), Value::from("p1")),
3092                        ]),
3093                        base_version: None,
3094                    }])
3095                    .unwrap();
3096            }
3097            let (_, meta) = client.build_request(false);
3098            // A malicious or stale response must not drain an unsent local commit.
3099            client
3100                .mutate(vec![Mutation::Upsert {
3101                    table: "tasks".to_owned(),
3102                    values: Map::from_iter([
3103                        ("id".to_owned(), Value::from("later")),
3104                        ("project_id".to_owned(), Value::from("p1")),
3105                    ]),
3106                    base_version: None,
3107                }])
3108                .unwrap();
3109            let ids = client.pending_commit_ids();
3110            let rows = client
3111                .query("SELECT * FROM tasks ORDER BY id", &[])
3112                .unwrap();
3113            let revision = client.local_revision();
3114            client.drain_change_batches();
3115            if failure != "later-frame" {
3116                client.conn.execute_batch(match failure {
3117                    "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",
3118                    "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",
3119                    _ => "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",
3120                }).unwrap();
3121            }
3122            let mut response = Message {
3123                wire_version: WIRE_VERSION,
3124                msg_kind: MsgKind::Response,
3125                frames: vec![Frame::RespHeader {
3126                    required_schema_version: None,
3127                    latest_schema_version: None,
3128                    log_epoch: Some("epoch-1".to_owned()),
3129                    reset_required: Some(false),
3130                }],
3131            };
3132            for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
3133                response.frames.push(Frame::PushResult {
3134                    client_commit_id: id.to_owned(),
3135                    status: PushStatus::Applied,
3136                    commit_seq: Some(1),
3137                    results: vec![OpResult::Applied { op_index: 0 }],
3138                });
3139            }
3140            if failure == "later-frame" {
3141                response.frames.push(Frame::Error {
3142                    code: "sync.invalid_request".to_owned(),
3143                    message: "later error".to_owned(),
3144                    category: "protocol".to_owned(),
3145                    retryable: false,
3146                    recommended_action: "retry".to_owned(),
3147                    details: None,
3148                });
3149            }
3150            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3151            let outcome = client.process_response(&mut transport, response.clone(), &meta);
3152            assert!(
3153                matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == if failure == "later-frame" { "sync.invalid_request" } else { "client.outcome_persistence_failed" })
3154            );
3155            if failure == "later-frame" {
3156                assert_eq!(client.pending_commit_ids(), ids[2..]);
3157                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
3158                assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
3159                let batches = client.drain_change_batches();
3160                assert_eq!(batches.len(), 1);
3161                assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
3162                assert_eq!(client.local_revision(), revision + 1);
3163                response.frames.pop();
3164            } else {
3165                assert_eq!(client.pending_commit_ids(), ids);
3166                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
3167                assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
3168                assert_eq!(client.local_revision(), revision);
3169                assert!(client.drain_change_batches().is_empty());
3170                assert_eq!(
3171                    client
3172                        .query("SELECT * FROM tasks ORDER BY id", &[])
3173                        .unwrap(),
3174                    rows
3175                );
3176                client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
3177            }
3178            for frame in &mut response.frames {
3179                if let Frame::PushResult { status, .. } = frame {
3180                    *status = PushStatus::Cached;
3181                }
3182            }
3183            let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
3184            else {
3185                panic!("retry failed");
3186            };
3187            assert_eq!(
3188                report.applied,
3189                if failure == "later-frame" {
3190                    vec![]
3191                } else {
3192                    ids[..2].to_vec()
3193                }
3194            );
3195            assert_eq!(client.pending_commit_ids(), ids[2..]);
3196            let batches = client.drain_change_batches();
3197            assert_eq!(batches.len(), usize::from(failure != "later-frame"));
3198            if let Some(batch) = batches.first() {
3199                assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
3200            }
3201            assert_eq!(client.local_revision(), revision + 1);
3202        }
3203    }
3204
3205    #[test]
3206    fn failed_push_result_preserves_memory_disk_and_retry_identity() {
3207        for failure in ["revision", "journal", "commit"] {
3208            for status in ["applied", "cached", "rejected", "conflict"] {
3209                let mut client = client();
3210                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3211                for id in ["first", "later"] {
3212                    client
3213                        .mutate(vec![Mutation::Upsert {
3214                            table: "tasks".to_owned(),
3215                            values: Map::from_iter([
3216                                ("id".to_owned(), Value::from(id)),
3217                                ("project_id".to_owned(), Value::from("p1")),
3218                            ]),
3219                            base_version: None,
3220                        }])
3221                        .unwrap();
3222                }
3223                let (_, meta) = client.build_request(false);
3224                let ids = client.pending_commit_ids();
3225                let rows = client
3226                    .query("SELECT * FROM tasks ORDER BY id", &[])
3227                    .unwrap();
3228                let revision = client.local_revision();
3229                client.drain_change_batches();
3230                let (push_status, results) = match status {
3231                    "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
3232                    "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
3233                    "conflict" => (
3234                        PushStatus::Rejected,
3235                        vec![OpResult::Conflict {
3236                            op_index: 0,
3237                            code: "sync.version_conflict".to_owned(),
3238                            message: "conflict".to_owned(),
3239                            server_version: 1,
3240                            server_row: encode_row_json(
3241                                client.schema.table("tasks").unwrap(),
3242                                "first",
3243                                &Map::from_iter([
3244                                    ("id".to_owned(), Value::from("first")),
3245                                    ("project_id".to_owned(), Value::from("p1")),
3246                                ]),
3247                                &client.encryption,
3248                            )
3249                            .unwrap(),
3250                            conflict_columns: Vec::new(),
3251                        }],
3252                    ),
3253                    _ => (
3254                        PushStatus::Rejected,
3255                        vec![OpResult::Error {
3256                            op_index: 0,
3257                            code: "sync.validation_failed".to_owned(),
3258                            message: "rejected".to_owned(),
3259                            retryable: false,
3260                        }],
3261                    ),
3262                };
3263                let response = Message {
3264                    wire_version: WIRE_VERSION,
3265                    msg_kind: MsgKind::Response,
3266                    frames: vec![
3267                        Frame::RespHeader {
3268                            required_schema_version: None,
3269                            latest_schema_version: None,
3270                            log_epoch: Some("epoch-1".to_owned()),
3271                            reset_required: Some(false),
3272                        },
3273                        Frame::PushResult {
3274                            client_commit_id: ids[0].clone(),
3275                            status: push_status,
3276                            commit_seq: Some(1),
3277                            results,
3278                        },
3279                    ],
3280                };
3281                client.conn.execute_batch(match failure {
3282                    "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
3283                    "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
3284                    _ => "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",
3285                }).unwrap();
3286                let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3287                let outcome = client.process_response(&mut transport, response.clone(), &meta);
3288                assert_eq!(
3289                    client.pending_commit_ids(),
3290                    ids,
3291                    "{failure}/{status}: pending identity"
3292                );
3293                assert_eq!(
3294                    client.local_revision(),
3295                    revision,
3296                    "{failure}/{status}: revision"
3297                );
3298                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
3299                assert!(client.conflicts().is_empty());
3300                assert!(client.rejections().is_empty());
3301                assert!(client.drain_change_batches().is_empty());
3302                assert_eq!(
3303                    client
3304                        .query("SELECT * FROM tasks ORDER BY id", &[])
3305                        .unwrap(),
3306                    rows
3307                );
3308                assert!(
3309                    matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == "client.outcome_persistence_failed"),
3310                    "{failure}/{status}: failed round"
3311                );
3312                client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
3313                assert!(matches!(
3314                    client.process_response(&mut transport, response, &meta),
3315                    SyncOutcome::Ok(_)
3316                ));
3317                assert_eq!(client.pending_commit_ids(), ids[1..]);
3318                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
3319                assert_eq!(client.drain_change_batches().len(), 1);
3320            }
3321        }
3322    }
3323
3324    #[test]
3325    fn mixed_push_results_reconcile_and_prune_once_per_response() {
3326        let mut client = client();
3327        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3328        for index in 0..4 {
3329            client
3330                .mutate(vec![Mutation::Upsert {
3331                    table: "tasks".to_owned(),
3332                    values: Map::from_iter([
3333                        ("id".to_owned(), Value::from(format!("task-{index}"))),
3334                        ("project_id".to_owned(), Value::from("project-1")),
3335                    ]),
3336                    base_version: None,
3337                }])
3338                .expect("queue commit");
3339        }
3340
3341        let (_, request_meta) = client.build_request(false);
3342        let ids = &request_meta.pushed_ids;
3343        assert_eq!(ids.len(), 4);
3344        let response = Message {
3345            wire_version: WIRE_VERSION,
3346            msg_kind: MsgKind::Response,
3347            frames: vec![
3348                Frame::RespHeader {
3349                    required_schema_version: None,
3350                    latest_schema_version: None,
3351                    log_epoch: Some("epoch-1".to_owned()),
3352                    reset_required: Some(false),
3353                },
3354                Frame::PushResult {
3355                    client_commit_id: ids[0].clone(),
3356                    status: PushStatus::Applied,
3357                    commit_seq: Some(1),
3358                    results: vec![OpResult::Applied { op_index: 0 }],
3359                },
3360                Frame::PushResult {
3361                    client_commit_id: ids[1].clone(),
3362                    status: PushStatus::Cached,
3363                    commit_seq: Some(2),
3364                    results: vec![OpResult::Applied { op_index: 0 }],
3365                },
3366                Frame::PushResult {
3367                    client_commit_id: ids[2].clone(),
3368                    status: PushStatus::Rejected,
3369                    commit_seq: None,
3370                    results: vec![OpResult::Error {
3371                        op_index: 0,
3372                        code: "sync.validation_failed".to_owned(),
3373                        message: "rejected".to_owned(),
3374                        retryable: false,
3375                    }],
3376                },
3377                Frame::PushResult {
3378                    client_commit_id: ids[3].clone(),
3379                    status: PushStatus::Rejected,
3380                    commit_seq: None,
3381                    results: vec![OpResult::Error {
3382                        op_index: 0,
3383                        code: "sync.idempotency_cache_miss".to_owned(),
3384                        message: "retry".to_owned(),
3385                        retryable: true,
3386                    }],
3387                },
3388            ],
3389        };
3390        let mut transport =
3391            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
3392
3393        client.overlay_rebuild_count.set(0);
3394        client.outcome_prune_count.set(0);
3395        let outcome = client.process_response(&mut transport, response, &request_meta);
3396        let SyncOutcome::Ok(report) = outcome else {
3397            panic!("mixed push-result response failed");
3398        };
3399
3400        assert_eq!(report.applied, ids[..2]);
3401        assert_eq!(report.rejected, ids[2..3]);
3402        assert_eq!(report.retryable, ids[3..4]);
3403        assert_eq!(client.pending_commit_ids(), ids[3..4]);
3404        assert_eq!(
3405            client
3406                .query("SELECT id FROM tasks ORDER BY id", &[])
3407                .expect("query visible overlay"),
3408            ["task-0", "task-1", "task-3"]
3409                .map(|id| Map::from_iter([("id".to_owned(), Value::from(id))]))
3410                .to_vec()
3411        );
3412        assert_eq!(client.overlay_rebuild_count.get(), 1);
3413        assert_eq!(client.outcome_prune_count.get(), 1);
3414    }
3415
3416    #[test]
3417    fn secondary_unique_collision_preserves_existing_synced_row() {
3418        let client = SyncClient::new(
3419            "unique-upsert-test".to_owned(),
3420            &json!({
3421                "version": 1,
3422                "tables": [{
3423                    "name": "tasks",
3424                    "primaryKey": "id",
3425                    "columns": [
3426                        { "name": "id", "type": "string", "nullable": false },
3427                        { "name": "project_id", "type": "string", "nullable": false },
3428                        { "name": "title", "type": "string", "nullable": false }
3429                    ],
3430                    "scopes": [{ "pattern": "project:{project_id}" }],
3431                    "indexes": [{
3432                        "name": "tasks_by_project_title",
3433                        "columns": ["project_id", "title"],
3434                        "unique": true
3435                    }]
3436                }]
3437            }),
3438            ClientLimits::default(),
3439        )
3440        .expect("test client");
3441        let table = client.schema.table("tasks").expect("tasks table");
3442        let sql = client.insert_row_sql(&base_table("tasks"), table);
3443
3444        client
3445            .conn
3446            .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
3447            .expect("insert first row");
3448        client
3449            .conn
3450            .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
3451            .expect("update same primary key");
3452        client
3453            .conn
3454            .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
3455            .expect("insert second row");
3456        assert!(client
3457            .conn
3458            .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
3459            .is_err());
3460
3461        let rows = client
3462            .conn
3463            .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
3464            .expect("prepare rows")
3465            .query_map([], |row| {
3466                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3467            })
3468            .expect("query rows")
3469            .collect::<Result<Vec<_>, _>>()
3470            .expect("collect rows");
3471        assert_eq!(
3472            rows,
3473            vec![
3474                ("t1".to_owned(), "updated".to_owned()),
3475                ("t2".to_owned(), "original".to_owned())
3476            ]
3477        );
3478    }
3479
3480    #[test]
3481    fn reopening_active_subscriptions_emits_a_catch_up_intent() {
3482        let path = std::env::temp_dir().join(format!(
3483            "syncular-startup-intent-{}.db",
3484            uuid::Uuid::new_v4()
3485        ));
3486        let schema = json!({
3487            "version": 1,
3488            "tables": [{
3489                "name": "tasks",
3490                "primaryKey": "id",
3491                "columns": [
3492                    { "name": "id", "type": "string", "nullable": false },
3493                    { "name": "project_id", "type": "string", "nullable": false }
3494                ],
3495                "scopes": [{ "pattern": "project:{project_id}" }]
3496            }]
3497        });
3498
3499        {
3500            let mut first = SyncClient::open_path_with_identity(
3501                None,
3502                &schema,
3503                ClientLimits::default(),
3504                path.to_str().expect("UTF-8 temp path"),
3505            )
3506            .expect("first open");
3507            first
3508                .set_window(
3509                    &WindowBase {
3510                        table: "tasks".to_owned(),
3511                        variable: "project_id".to_owned(),
3512                        fixed_scopes: Vec::new(),
3513                        params: None,
3514                    },
3515                    &["persisted".to_owned()],
3516                )
3517                .expect("persist window");
3518        }
3519
3520        let mut reopened = SyncClient::open_path_with_identity(
3521            None,
3522            &schema,
3523            ClientLimits::default(),
3524            path.to_str().expect("UTF-8 temp path"),
3525        )
3526        .expect("reopen");
3527        assert!(reopened.sync_needed());
3528        assert!(matches!(
3529            reopened.drain_sync_intents().as_slice(),
3530            [SyncIntent::Interactive]
3531        ));
3532        drop(reopened);
3533        std::fs::remove_file(path).expect("remove temp database");
3534    }
3535
3536    #[test]
3537    fn reopening_preserves_immutable_subscription_identity_and_progress() {
3538        let path = std::env::temp_dir().join(format!(
3539            "syncular-subscription-identity-{}.db",
3540            uuid::Uuid::new_v4()
3541        ));
3542        let schema = json!({
3543            "version": 1,
3544            "tables": [
3545                {
3546                    "name": "tasks",
3547                    "primaryKey": "id",
3548                    "columns": [
3549                        { "name": "id", "type": "string", "nullable": false },
3550                        { "name": "project_id", "type": "string", "nullable": false }
3551                    ],
3552                    "scopes": [{ "pattern": "project:{project_id}" }]
3553                },
3554                {
3555                    "name": "docs",
3556                    "primaryKey": "id",
3557                    "columns": [
3558                        { "name": "id", "type": "string", "nullable": false },
3559                        { "name": "org_id", "type": "string", "nullable": false },
3560                        { "name": "project_id", "type": "string", "nullable": false }
3561                    ],
3562                    "scopes": [
3563                        { "pattern": "org:{org_id}" },
3564                        { "pattern": "project:{projectId}", "column": "project_id" }
3565                    ]
3566                }
3567            ]
3568        });
3569
3570        {
3571            let mut first = SyncClient::open_path_with_identity(
3572                None,
3573                &schema,
3574                ClientLimits::default(),
3575                path.to_str().expect("UTF-8 temp path"),
3576            )
3577            .expect("first open");
3578            first
3579                .subscribe(
3580                    "stable-subscription".to_owned(),
3581                    "tasks".to_owned(),
3582                    vec![(
3583                        "project_id".to_owned(),
3584                        vec!["p2".to_owned(), "p1".to_owned()],
3585                    )],
3586                    Some(r#"{"view":"v1"}"#.to_owned()),
3587                )
3588                .expect("persist subscription");
3589            let persisted = {
3590                let subscription = first
3591                    .subs
3592                    .iter_mut()
3593                    .find(|subscription| subscription.id == "stable-subscription")
3594                    .expect("subscription");
3595                subscription.cursor = 41;
3596                subscription.bootstrap_state = Some("resume-token".to_owned());
3597                subscription.effective = Some(vec![(
3598                    "project_id".to_owned(),
3599                    vec!["p1".to_owned(), "p2".to_owned()],
3600                )]);
3601                subscription.synced_once = true;
3602                subscription.clone()
3603            };
3604            first.persist_sub(&persisted).unwrap();
3605        }
3606
3607        let mut reopened = SyncClient::open_path_with_identity(
3608            None,
3609            &schema,
3610            ClientLimits::default(),
3611            path.to_str().expect("UTF-8 temp path"),
3612        )
3613        .expect("reopen");
3614        let progress = reopened
3615            .subscription_state("stable-subscription")
3616            .expect("persisted state");
3617        assert_eq!(progress.cursor, 41);
3618        assert!(progress.has_resume_token);
3619
3620        reopened
3621            .subscribe(
3622                "stable-subscription".to_owned(),
3623                "tasks".to_owned(),
3624                vec![(
3625                    "project_id".to_owned(),
3626                    vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
3627                )],
3628                Some(r#"{"view":"v1"}"#.to_owned()),
3629            )
3630            .expect("canonical intent is idempotent");
3631        assert_eq!(
3632            reopened
3633                .subscription_state("stable-subscription")
3634                .expect("unchanged state")
3635                .cursor,
3636            progress.cursor
3637        );
3638
3639        for (table, scopes, params) in [
3640            (
3641                "tasks",
3642                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3643                Some(r#"{"view":"v1"}"#.to_owned()),
3644            ),
3645            (
3646                "tasks",
3647                vec![(
3648                    "project_id".to_owned(),
3649                    vec!["p2".to_owned(), "p1".to_owned()],
3650                )],
3651                Some(r#"{"view":"v2"}"#.to_owned()),
3652            ),
3653            (
3654                "docs",
3655                vec![
3656                    ("org_id".to_owned(), vec!["o1".to_owned()]),
3657                    ("projectId".to_owned(), vec!["p1".to_owned()]),
3658                ],
3659                Some(r#"{"view":"v1"}"#.to_owned()),
3660            ),
3661        ] {
3662            let error = reopened
3663                .subscribe(
3664                    "stable-subscription".to_owned(),
3665                    table.to_owned(),
3666                    scopes,
3667                    params,
3668                )
3669                .expect_err("identity rebind must fail");
3670            assert!(error.starts_with("client.subscription_intent_mismatch:"));
3671            assert_eq!(
3672                reopened
3673                    .subscription_state("stable-subscription")
3674                    .expect("unchanged state")
3675                    .cursor,
3676                progress.cursor
3677            );
3678        }
3679
3680        drop(reopened);
3681        std::fs::remove_file(path).expect("remove temp database");
3682    }
3683
3684    #[test]
3685    fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
3686        let path = std::env::temp_dir().join(format!(
3687            "syncular-satisfied-schema-floor-{}.db",
3688            uuid::Uuid::new_v4()
3689        ));
3690        let schema = json!({
3691            "version": 23,
3692            "tables": [{
3693                "name": "tasks",
3694                "primaryKey": "id",
3695                "columns": [
3696                    { "name": "id", "type": "string", "nullable": false },
3697                    { "name": "project_id", "type": "string", "nullable": false }
3698                ],
3699                "scopes": [{ "pattern": "project:{project_id}" }]
3700            }]
3701        });
3702
3703        {
3704            let mut first = SyncClient::open_path_with_identity(
3705                None,
3706                &schema,
3707                ClientLimits::default(),
3708                path.to_str().expect("UTF-8 temp path"),
3709            )
3710            .expect("first open");
3711            first
3712                .subscribe(
3713                    "tasks".to_owned(),
3714                    "tasks".to_owned(),
3715                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3716                    None,
3717                )
3718                .expect("persist subscription");
3719            first.set_schema_floor(Some(SchemaFloor {
3720                required_schema_version: Some(22),
3721                latest_schema_version: Some(22),
3722            }));
3723        }
3724
3725        let mut reopened = SyncClient::open_path_with_identity(
3726            None,
3727            &schema,
3728            ClientLimits::default(),
3729            path.to_str().expect("UTF-8 temp path"),
3730        )
3731        .expect("reopen");
3732        assert!(reopened.schema_floor().is_none());
3733        assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
3734        assert!(reopened.sync_needed());
3735        assert!(matches!(
3736            reopened.drain_sync_intents().as_slice(),
3737            [SyncIntent::Interactive]
3738        ));
3739        drop(reopened);
3740        std::fs::remove_file(path).expect("remove temp database");
3741    }
3742
3743    #[test]
3744    fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
3745        let path = std::env::temp_dir().join(format!(
3746            "syncular-unsatisfied-schema-floor-{}.db",
3747            uuid::Uuid::new_v4()
3748        ));
3749        let schema = json!({
3750            "version": 1,
3751            "tables": [{
3752                "name": "tasks",
3753                "primaryKey": "id",
3754                "columns": [
3755                    { "name": "id", "type": "string", "nullable": false },
3756                    { "name": "project_id", "type": "string", "nullable": false }
3757                ],
3758                "scopes": [{ "pattern": "project:{project_id}" }]
3759            }]
3760        });
3761
3762        {
3763            let mut first = SyncClient::open_path_with_identity(
3764                None,
3765                &schema,
3766                ClientLimits::default(),
3767                path.to_str().expect("UTF-8 temp path"),
3768            )
3769            .expect("first open");
3770            first
3771                .subscribe(
3772                    "tasks".to_owned(),
3773                    "tasks".to_owned(),
3774                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3775                    None,
3776                )
3777                .expect("persist subscription");
3778            first.set_schema_floor(Some(SchemaFloor {
3779                required_schema_version: Some(2),
3780                latest_schema_version: Some(2),
3781            }));
3782        }
3783
3784        let reopened = SyncClient::open_path_with_identity(
3785            None,
3786            &schema,
3787            ClientLimits::default(),
3788            path.to_str().expect("UTF-8 temp path"),
3789        )
3790        .expect("reopen");
3791        assert_eq!(
3792            reopened.schema_floor(),
3793            Some(&SchemaFloor {
3794                required_schema_version: Some(2),
3795                latest_schema_version: Some(2),
3796            })
3797        );
3798        assert!(!reopened.sync_needed());
3799        drop(reopened);
3800        std::fs::remove_file(path).expect("remove temp database");
3801    }
3802
3803    #[test]
3804    fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
3805        let path = std::env::temp_dir().join(format!(
3806            "syncular-indexed-column-bump-{}.db",
3807            uuid::Uuid::new_v4()
3808        ));
3809        let old_schema = json!({
3810            "version": 1,
3811            "tables": [
3812                {
3813                    "name": "tasks",
3814                    "primaryKey": "id",
3815                    "columns": [
3816                        { "name": "id", "type": "string", "nullable": false },
3817                        { "name": "project_id", "type": "string", "nullable": false }
3818                    ],
3819                    "scopes": [{ "pattern": "project:{project_id}" }]
3820                },
3821                {
3822                    "name": "legacy",
3823                    "primaryKey": "id",
3824                    "columns": [
3825                        { "name": "id", "type": "string", "nullable": false },
3826                        { "name": "project_id", "type": "string", "nullable": false }
3827                    ],
3828                    "scopes": [{ "pattern": "project:{project_id}" }]
3829                }
3830            ]
3831        });
3832        {
3833            let mut first = SyncClient::open_path_with_identity(
3834                None,
3835                &old_schema,
3836                ClientLimits::default(),
3837                path.to_str().expect("UTF-8 temp path"),
3838            )
3839            .expect("open old schema");
3840            first
3841                .subscribe(
3842                    "legacy-sub".to_owned(),
3843                    "legacy".to_owned(),
3844                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3845                    None,
3846                )
3847                .expect("persist legacy subscription");
3848        }
3849
3850        let new_schema = json!({
3851            "version": 2,
3852            "tables": [{
3853                "name": "tasks",
3854                "primaryKey": "id",
3855                "columns": [
3856                    { "name": "id", "type": "string", "nullable": false },
3857                    { "name": "project_id", "type": "string", "nullable": false },
3858                    { "name": "facility_membership_id", "type": "string", "nullable": true }
3859                ],
3860                "scopes": [{ "pattern": "project:{project_id}" }],
3861                "indexes": [{
3862                    "name": "tasks_by_membership",
3863                    "columns": ["project_id", "facility_membership_id"],
3864                    "unique": false
3865                }]
3866            }]
3867        });
3868        let upgraded = SyncClient::open_path_with_identity(
3869            None,
3870            &new_schema,
3871            ClientLimits::default(),
3872            path.to_str().expect("UTF-8 temp path"),
3873        )
3874        .expect("open upgraded schema");
3875        let columns = upgraded
3876            .conn
3877            .prepare("PRAGMA table_info(tasks)")
3878            .expect("prepare columns")
3879            .query_map([], |row| row.get::<_, String>(1))
3880            .expect("query columns")
3881            .collect::<Result<Vec<_>, _>>()
3882            .expect("collect columns");
3883        assert!(columns.contains(&"facility_membership_id".to_owned()));
3884        let index_count: i64 = upgraded
3885            .conn
3886            .query_row(
3887                "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
3888                [],
3889                |row| row.get(0),
3890            )
3891            .expect("query index");
3892        assert_eq!(index_count, 1);
3893        assert!(upgraded.subscription_state("legacy-sub").is_none());
3894        drop(upgraded);
3895        std::fs::remove_file(path).expect("remove temp database");
3896    }
3897
3898    #[test]
3899    fn migrates_pre_envelope_outcome_journal_additively() {
3900        let path = std::env::temp_dir().join(format!(
3901            "syncular-outcome-migration-{}.db",
3902            uuid::Uuid::new_v4()
3903        ));
3904        let conn = Connection::open(&path).expect("open legacy database");
3905        conn.execute_batch(
3906            "CREATE TABLE _syncular_commit_outcomes (
3907               seq INTEGER PRIMARY KEY AUTOINCREMENT,
3908               client_commit_id TEXT NOT NULL UNIQUE,
3909               status TEXT NOT NULL,
3910               recorded_at_ms INTEGER NOT NULL,
3911               results_json TEXT NOT NULL,
3912               resolution TEXT NOT NULL DEFAULT 'active',
3913               resolved_at_ms INTEGER,
3914               replacement_client_commit_id TEXT);",
3915        )
3916        .expect("create legacy outcome journal");
3917        drop(conn);
3918        let schema = json!({
3919            "version": 1,
3920            "tables": [{
3921                "name": "tasks",
3922                "primaryKey": "id",
3923                "columns": [
3924                    { "name": "id", "type": "string", "nullable": false },
3925                    { "name": "project_id", "type": "string", "nullable": false }
3926                ],
3927                "scopes": [{ "pattern": "project:{project_id}" }]
3928            }]
3929        });
3930        let client = SyncClient::open_path(
3931            "migration-native".to_owned(),
3932            &schema,
3933            ClientLimits::default(),
3934            path.to_str().expect("UTF-8 temp path"),
3935        )
3936        .expect("migrate database");
3937        let has_operations = client
3938            .conn
3939            .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
3940            .expect("prepare table info")
3941            .query_map([], |row| row.get::<_, String>(1))
3942            .expect("query table info")
3943            .filter_map(Result::ok)
3944            .any(|column| column == "operations_json");
3945        assert!(has_operations);
3946        drop(client);
3947        std::fs::remove_file(path).expect("remove temp database");
3948    }
3949
3950    #[test]
3951    fn retained_rejection_survives_file_reopen_and_security_purge() {
3952        let path =
3953            std::env::temp_dir().join(format!("syncular-retained-{}.db", uuid::Uuid::new_v4()));
3954        let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id", "columns": [
3955            { "name": "id", "type": "string", "nullable": false },
3956            { "name": "project_id", "type": "string", "nullable": false },
3957            { "name": "title", "type": "string", "nullable": false }
3958        ], "scopes": [{ "pattern": "project:{project_id}" }] }] });
3959        {
3960            let mut first = SyncClient::open_path(
3961                "retained-native".to_owned(),
3962                &schema,
3963                ClientLimits::default(),
3964                path.to_str().unwrap(),
3965            )
3966            .unwrap();
3967            first
3968                .write_base_row(
3969                    "tasks",
3970                    &vec![
3971                        Some(ColumnValue::String("t1".into())),
3972                        Some(ColumnValue::String("p1".into())),
3973                        Some(ColumnValue::String("server".into())),
3974                    ],
3975                    1,
3976                )
3977                .unwrap();
3978            first.rebuild_overlay();
3979            let operation = OutboxOp {
3980                table: "tasks".into(),
3981                row_id: "t1".into(),
3982                upsert: true,
3983                base_version: Some(1),
3984                values: Some(Map::from_iter([
3985                    ("id".into(), json!("t1")),
3986                    ("title".into(), json!("mine")),
3987                ])),
3988            };
3989            first
3990                .retain_failed_commit("retained", std::slice::from_ref(&operation))
3991                .unwrap();
3992            first
3993                .persist_commit_outcome(
3994                    "retained",
3995                    CommitOutcomeStatus::Rejected,
3996                    &[],
3997                    Some(&[operation]),
3998                )
3999                .unwrap();
4000            first.rebuild_overlay();
4001        }
4002        {
4003            let mut reopened = SyncClient::open_path(
4004                "retained-native".to_owned(),
4005                &schema,
4006                ClientLimits::default(),
4007                path.to_str().unwrap(),
4008            )
4009            .unwrap();
4010            let row = reopened
4011                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
4012                .unwrap();
4013            assert_eq!(row.0[2], Some(ColumnValue::String("mine".into())));
4014            assert_eq!(
4015                reopened
4016                    .commit_outcome("retained")
4017                    .unwrap()
4018                    .unwrap()
4019                    .retained_rows
4020                    .unwrap()[0]
4021                    .server_row
4022                    .as_ref()
4023                    .unwrap()["title"],
4024                json!("server")
4025            );
4026            reopened
4027                .conn
4028                .execute("DELETE FROM _syncular_base_tasks WHERE id = 't1'", [])
4029                .unwrap();
4030            reopened.rebuild_overlay();
4031            assert!(reopened
4032                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
4033                .is_none());
4034            let retained = reopened.commit_outcome("retained").unwrap().unwrap();
4035            assert!(retained.retained_rows.is_some());
4036            assert_eq!(
4037                retained.operations.unwrap()[0].values.as_ref().unwrap()["title"],
4038                json!("mine")
4039            );
4040            reopened
4041                .purge_local_data(&LocalDataPurgeInput {
4042                    purge_id: "protected-purge".into(),
4043                    targets: vec![LocalDataPurgeTarget {
4044                        table: "tasks".into(),
4045                        selectors: BTreeMap::from_iter([("project_id".into(), vec!["p1".into()])]),
4046                    }],
4047                })
4048                .unwrap();
4049            assert!(reopened
4050                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
4051                .is_none());
4052            assert!(reopened
4053                .commit_outcome("retained")
4054                .unwrap()
4055                .unwrap()
4056                .retained_rows
4057                .is_none());
4058        }
4059        std::fs::remove_file(path).unwrap();
4060    }
4061
4062    #[test]
4063    fn durable_conflict_outcome_and_resolution_survive_reopen() {
4064        let path = std::env::temp_dir().join(format!(
4065            "syncular-durable-outcome-{}.db",
4066            uuid::Uuid::new_v4()
4067        ));
4068        let schema = json!({
4069            "version": 1,
4070            "tables": [{
4071                "name": "tasks",
4072                "primaryKey": "id",
4073                "columns": [
4074                    { "name": "id", "type": "string", "nullable": false },
4075                    { "name": "project_id", "type": "string", "nullable": false }
4076                ],
4077                "scopes": [{ "pattern": "project:{project_id}" }]
4078            }]
4079        });
4080        let conflict = ConflictRecord {
4081            client_commit_id: "losing-commit".to_owned(),
4082            op_index: 0,
4083            table: "tasks".to_owned(),
4084            row_id: "t1".to_owned(),
4085            code: "sync.version_conflict".to_owned(),
4086            message: "stale base version".to_owned(),
4087            server_version: 2,
4088            server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
4089            conflict_columns: vec!["project_id".to_owned()],
4090            operation: Some(CommitOperation {
4091                table: "tasks".to_owned(),
4092                row_id: "t1".to_owned(),
4093                op: "upsert".to_owned(),
4094                base_version: Some(1),
4095                values: None,
4096            }),
4097        };
4098        let failed_operations = vec![
4099            OutboxOp {
4100                upsert: true,
4101                table: "tasks".to_owned(),
4102                row_id: "t1".to_owned(),
4103                base_version: Some(1),
4104                values: None,
4105            },
4106            OutboxOp {
4107                upsert: true,
4108                table: "tasks".to_owned(),
4109                row_id: "status-event-1".to_owned(),
4110                base_version: Some(0),
4111                values: None,
4112            },
4113        ];
4114
4115        {
4116            let mut first = SyncClient::open_path(
4117                "durable-native".to_owned(),
4118                &schema,
4119                ClientLimits::default(),
4120                path.to_str().expect("UTF-8 temp path"),
4121            )
4122            .expect("first open");
4123            first
4124                .begin_observation("test_outcome")
4125                .expect("begin outcome");
4126            first
4127                .persist_commit_outcome(
4128                    "losing-commit",
4129                    CommitOutcomeStatus::Conflict,
4130                    &[CommitOperationOutcome::Conflict {
4131                        conflict: conflict.clone(),
4132                    }],
4133                    Some(&failed_operations),
4134                )
4135                .expect("persist outcome");
4136            first.conflicts.push(conflict);
4137            first
4138                .finish_observation(
4139                    "test_outcome",
4140                    ChangeAccumulator {
4141                        conflicts: true,
4142                        outcomes: true,
4143                        ..ChangeAccumulator::default()
4144                    },
4145                )
4146                .expect("commit outcome");
4147        }
4148
4149        {
4150            let mut reopened = SyncClient::open_path(
4151                "durable-native".to_owned(),
4152                &schema,
4153                ClientLimits::default(),
4154                path.to_str().expect("UTF-8 temp path"),
4155            )
4156            .expect("reopen");
4157            assert_eq!(reopened.conflicts().len(), 1);
4158            assert_eq!(
4159                reopened.conflicts()[0].conflict_columns,
4160                vec!["project_id".to_owned()],
4161                "conflictColumns survives the journal round trip"
4162            );
4163            let outcome = reopened
4164                .commit_outcome("losing-commit")
4165                .expect("read outcome")
4166                .expect("outcome");
4167            assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
4168            let operations = outcome.operations.expect("aggregate envelope");
4169            assert_eq!(operations.len(), 2);
4170            assert_eq!(operations[1].row_id, "status-event-1");
4171            let resolved = reopened
4172                .resolve_commit_outcome(ResolveCommitOutcomeInput {
4173                    client_commit_id: "losing-commit".to_owned(),
4174                    resolution: CommitOutcomeResolution::ResolvedKeepServer,
4175                    replacement_client_commit_id: None,
4176                })
4177                .expect("resolve");
4178            assert_eq!(
4179                resolved.resolution,
4180                CommitOutcomeResolution::ResolvedKeepServer
4181            );
4182            assert!(reopened.conflicts().is_empty());
4183        }
4184
4185        let reopened = SyncClient::open_path(
4186            "durable-native".to_owned(),
4187            &schema,
4188            ClientLimits::default(),
4189            path.to_str().expect("UTF-8 temp path"),
4190        )
4191        .expect("second reopen");
4192        assert!(reopened.conflicts().is_empty());
4193        assert_eq!(
4194            reopened
4195                .commit_outcome("losing-commit")
4196                .expect("read outcome")
4197                .expect("outcome")
4198                .resolution,
4199            CommitOutcomeResolution::ResolvedKeepServer
4200        );
4201        drop(reopened);
4202        std::fs::remove_file(path).expect("remove temp database");
4203    }
4204
4205    #[test]
4206    fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
4207        let path =
4208            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
4209        let schema = json!({
4210            "version": 1,
4211            "tables": [{
4212                "name": "tasks",
4213                "primaryKey": "id",
4214                "columns": [
4215                    { "name": "id", "type": "string", "nullable": false },
4216                    { "name": "project_id", "type": "string", "nullable": false }
4217                ],
4218                "scopes": [{ "pattern": "project:{project_id}" }]
4219            }]
4220        });
4221        let mut client = SyncClient::open_path_with_identity(
4222            Some("sidecar-client".to_owned()),
4223            &schema,
4224            ClientLimits::default(),
4225            path.to_str().expect("UTF-8 temp path"),
4226        )
4227        .expect("open owner");
4228        let base = WindowBase {
4229            table: "tasks".to_owned(),
4230            variable: "project_id".to_owned(),
4231            fixed_scopes: Vec::new(),
4232            params: None,
4233        };
4234        client
4235            .set_window(&base, &["one".to_owned()])
4236            .expect("set window");
4237        client
4238            .mutate(vec![Mutation::Upsert {
4239                table: "tasks".to_owned(),
4240                values: Map::from_iter([
4241                    ("id".to_owned(), Value::from("t1")),
4242                    ("project_id".to_owned(), Value::from("one")),
4243                ]),
4244                base_version: None,
4245            }])
4246            .expect("local mutate");
4247
4248        let coverage = [WindowCoverage {
4249            base,
4250            units: vec!["one".to_owned(), "missing".to_owned()],
4251        }];
4252        let owner = client
4253            .query_snapshot(
4254                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
4255                &[],
4256                &coverage,
4257                None,
4258            )
4259            .expect("owner snapshot");
4260        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
4261        let sidecar = reader
4262            .query_snapshot(
4263                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
4264                &[],
4265                &coverage,
4266            )
4267            .expect("sidecar snapshot");
4268
4269        assert_eq!(sidecar.revision, owner.revision);
4270        assert_eq!(sidecar.rows, owner.rows);
4271        assert_eq!(
4272            serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
4273            serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
4274        );
4275        assert_eq!(sidecar.revision, "2");
4276        assert_eq!(sidecar.rows[0]["id"], "t1");
4277        assert_eq!(sidecar.rows[0]["server_version"], -1);
4278        assert!(!sidecar.coverage.complete);
4279        assert_eq!(sidecar.coverage.pending.len(), 1);
4280        assert_eq!(sidecar.coverage.missing.len(), 1);
4281
4282        drop(reader);
4283        drop(client);
4284        std::fs::remove_file(path).expect("remove temp database");
4285    }
4286
4287    #[test]
4288    fn owned_query_failures_classify_order_bound_and_clear() {
4289        let io = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
4290            rusqlite::ffi::Error::new(266),
4291            Some("private SQLite prose".to_owned()),
4292        ));
4293        assert_eq!(io.code, Some("client.storage_io"));
4294        assert_eq!(io.sqlite_code, Some(266));
4295        assert_eq!(io.message, "local SQLite storage I/O failed");
4296        let corrupt = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
4297            rusqlite::ffi::Error::new(26),
4298            None,
4299        ));
4300        assert_eq!(corrupt.code, Some("client.storage_corrupt"));
4301
4302        let mut client = client();
4303        let generic = QueryReadFailure::from("private query prose".to_owned());
4304        client.record_query_read(
4305            QueryOwner {
4306                id: "first",
4307                tables: &["tasks", "tasks"],
4308            },
4309            Some(&generic),
4310        );
4311        client.record_query_read(
4312            QueryOwner {
4313                id: "second",
4314                tables: &["tasks"],
4315            },
4316            Some(&generic),
4317        );
4318        client.record_query_read(
4319            QueryOwner {
4320                id: "first",
4321                tables: &["docs"],
4322            },
4323            Some(&io),
4324        );
4325        let failures = &client
4326            .diagnostics_snapshot(&Default::default())
4327            .expect("diagnostics")
4328            .query_failures;
4329        assert_eq!(
4330            failures
4331                .iter()
4332                .map(|entry| entry.id.as_str())
4333                .collect::<Vec<_>>(),
4334            vec!["second", "first"]
4335        );
4336        assert_eq!(failures[1].tables, vec!["docs"]);
4337        assert_eq!(failures[1].code, "client.storage_io");
4338
4339        for index in 0..=MAX_DIAGNOSTIC_QUERY_FAILURES {
4340            client.record_query_read(
4341                QueryOwner {
4342                    id: &format!("query-{index}"),
4343                    tables: &["tasks"],
4344                },
4345                Some(&generic),
4346            );
4347        }
4348        let failures = &client
4349            .diagnostics_snapshot(&Default::default())
4350            .expect("bounded diagnostics")
4351            .query_failures;
4352        assert_eq!(failures.len(), MAX_DIAGNOSTIC_QUERY_FAILURES);
4353        assert_eq!(failures.last().expect("last failure").id, "query-256");
4354
4355        client
4356            .query_snapshot(
4357                "SELECT id FROM tasks",
4358                &[],
4359                &[],
4360                Some(QueryOwner {
4361                    id: "query-256",
4362                    tables: &["tasks"],
4363                }),
4364            )
4365            .expect("successful owned read");
4366        assert!(client
4367            .diagnostics_snapshot(&Default::default())
4368            .expect("cleared diagnostics")
4369            .query_failures
4370            .iter()
4371            .all(|entry| entry.id != "query-256"));
4372    }
4373
4374    #[test]
4375    fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
4376        let mut client = client();
4377        let image = Connection::open_in_memory().unwrap();
4378        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
4379            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
4380            INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
4381            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4382            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
4383        let table = client.schema.table("tasks").unwrap().clone();
4384        let scopes = vec![("project_id".into(), vec!["p1".into()])];
4385        assert!(client
4386            .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
4387            .is_err());
4388        assert!(client.conn.is_autocommit());
4389        assert_eq!(
4390            client
4391                .query("SELECT count(*) AS n FROM tasks", &[])
4392                .unwrap()[0]["n"],
4393            1024
4394        );
4395        assert_eq!(client.drain_change_batches().len(), 1);
4396        image
4397            .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
4398            .unwrap();
4399        assert!(matches!(
4400            client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
4401            Ok(2050)
4402        ));
4403        assert_eq!(
4404            client
4405                .query("SELECT count(*) AS n FROM tasks", &[])
4406                .unwrap()[0]["n"],
4407            2050
4408        );
4409        assert_eq!(client.drain_change_batches().len(), 3);
4410    }
4411
4412    #[test]
4413    fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
4414        let mut client = client();
4415        let base = WindowBase {
4416            table: "tasks".into(),
4417            variable: "project_id".into(),
4418            fixed_scopes: Vec::new(),
4419            params: None,
4420        };
4421        client
4422            .set_window(&base, &["p1".into(), "p2".into()])
4423            .unwrap();
4424        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
4425            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
4426            INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
4427            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
4428            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
4429        client.drain_change_batches();
4430        assert!(client.set_window(&base, &["p2".into()]).is_err());
4431        assert!(client.conn.is_autocommit());
4432        assert_eq!(
4433            client
4434                .query("SELECT count(*) AS n FROM tasks", &[])
4435                .unwrap()[0]["n"],
4436            1027
4437        );
4438        assert_eq!(client.window_state(&base).units, vec!["p2"]);
4439        assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
4440        assert_eq!(client.drain_change_batches().len(), 1);
4441        client
4442            .conn
4443            .execute_batch("DROP TRIGGER interrupt_evict")
4444            .unwrap();
4445        client.drain_pending_evictions().unwrap();
4446        assert!(client.load_pending_evictions().unwrap().is_empty());
4447        assert_eq!(
4448            client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
4449            "held"
4450        );
4451        assert_eq!(client.drain_change_batches().len(), 2);
4452    }
4453
4454    #[test]
4455    fn eviction_marker_failures_roll_back_window_changes() {
4456        let mut client = client();
4457        let base = WindowBase {
4458            table: "tasks".into(),
4459            variable: "project_id".into(),
4460            fixed_scopes: Vec::new(),
4461            params: None,
4462        };
4463        client.set_window(&base, &["p1".into()]).unwrap();
4464        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
4465            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
4466            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
4467            CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
4468        let revision = client.local_revision();
4469        assert!(client.set_window(&base, &[]).is_err());
4470        assert!(client.conn.is_autocommit());
4471        assert_eq!(client.local_revision(), revision);
4472        assert_eq!(
4473            client
4474                .query("SELECT count(*) AS n FROM tasks", &[])
4475                .unwrap()[0]["n"],
4476            2050
4477        );
4478        assert_eq!(client.window_state(&base).units, vec!["p1"]);
4479        client.conn.execute_batch("DROP TRIGGER fail_marker;
4480            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
4481        assert!(client.set_window(&base, &[]).is_err());
4482        client.conn.execute_batch("DROP TRIGGER interrupt_evict;
4483            CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
4484        assert!(client.drain_pending_evictions().is_err());
4485        assert!(client.conn.is_autocommit());
4486        assert_eq!(
4487            client
4488                .query("SELECT count(*) AS n FROM tasks", &[])
4489                .unwrap()[0]["n"],
4490            2
4491        );
4492        assert!(client.set_window(&base, &["p1".into()]).is_err());
4493        assert!(client.conn.is_autocommit());
4494        assert!(client.window_state(&base).units.is_empty());
4495        assert!(client.subs.is_empty());
4496        assert_eq!(
4497            client
4498                .query(
4499                    "SELECT count(*) AS n FROM _syncular_window_pending_evict",
4500                    &[]
4501                )
4502                .unwrap()[0]["n"],
4503            1
4504        );
4505        client
4506            .conn
4507            .execute_batch("DROP TRIGGER fail_marker")
4508            .unwrap();
4509        client.set_window(&base, &["p1".into()]).unwrap();
4510        assert_eq!(client.window_state(&base).units, vec!["p1"]);
4511    }
4512
4513    #[test]
4514    fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
4515        let mut client = client();
4516        client
4517            .conn
4518            .execute(
4519                "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
4520                [],
4521            )
4522            .unwrap();
4523        assert_eq!(
4524            client.drain_pending_evictions().unwrap_err(),
4525            "sync.local_corrupt: invalid pending eviction scopes"
4526        );
4527        assert!(client.conn.is_autocommit());
4528        assert!(client.enqueue_startup_sync_if_needed().is_err());
4529    }
4530
4531    #[test]
4532    fn image_chunks_reject_null_primary_keys() {
4533        let mut client = client();
4534        let image = Connection::open_in_memory().unwrap();
4535        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4536            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
4537            INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
4538            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4539            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
4540        let table = client.schema.table("tasks").unwrap().clone();
4541        assert!(client
4542            .apply_sqlite_image(
4543                &image,
4544                &table,
4545                true,
4546                &[("project_id".into(), vec!["p1".into()])],
4547                1025,
4548                7,
4549                "digest"
4550            )
4551            .is_err());
4552        assert!(client.conn.is_autocommit());
4553        assert_eq!(
4554            client
4555                .query("SELECT count(*) AS n FROM tasks", &[])
4556                .unwrap()[0]["n"],
4557            0
4558        );
4559    }
4560
4561    #[test]
4562    fn import_chunks_with_unique_constraints_match_full_pending_replay() {
4563        for images in [false, true] {
4564            let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
4565                "name":name,"primaryKey":"id","columns":[
4566                    {"name":"id","type":"string","nullable":false},
4567                    {"name":"project_id","type":"string","nullable":false},
4568                    {"name":"title","type":"string","nullable":false}],
4569                "scopes":[{"pattern":"project:{project_id}"}],
4570                "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
4571                "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
4572            })))});
4573            let mut actual =
4574                SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
4575            let mut reference =
4576                SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
4577            let scopes = vec![("project_id".into(), vec!["p1".into()])];
4578            for client in [&mut actual, &mut reference] {
4579                client
4580                    .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
4581                    .unwrap();
4582                client.subs[0].effective = Some(scopes.clone());
4583                for (table, id, title) in [
4584                    ("tasks", "a", "original"),
4585                    ("tasks", "b", "occupied"),
4586                    ("tasks", "c", "deleted"),
4587                    ("notes", "n", "untouched"),
4588                ] {
4589                    client
4590                        .write_base_row(
4591                            table,
4592                            &vec![
4593                                Some(ColumnValue::String(id.into())),
4594                                Some(ColumnValue::String("p1".into())),
4595                                Some(ColumnValue::String(title.into())),
4596                            ],
4597                            1,
4598                        )
4599                        .unwrap();
4600                }
4601                client.rebuild_overlay_if_dirty();
4602                client
4603                    .mutate(vec![
4604                        Mutation::Upsert {
4605                            table: "tasks".into(),
4606                            values: Map::from_iter([
4607                                ("id".into(), Value::from("a")),
4608                                ("project_id".into(), Value::from("p1")),
4609                                ("title".into(), Value::from("available")),
4610                            ]),
4611                            base_version: None,
4612                        },
4613                        Mutation::Delete {
4614                            table: "tasks".into(),
4615                            row_id: "c".into(),
4616                            base_version: None,
4617                        },
4618                        Mutation::Upsert {
4619                            table: "tasks".into(),
4620                            values: Map::from_iter([
4621                                ("id".into(), Value::from("local")),
4622                                ("project_id".into(), Value::from("p1")),
4623                                ("title".into(), Value::from("local value")),
4624                            ]),
4625                            base_version: None,
4626                        },
4627                        Mutation::Upsert {
4628                            table: "notes".into(),
4629                            values: Map::from_iter([
4630                                ("id".into(), Value::from("n")),
4631                                ("project_id".into(), Value::from("p1")),
4632                                ("title".into(), Value::from("pending note")),
4633                            ]),
4634                            base_version: None,
4635                        },
4636                    ])
4637                    .unwrap();
4638            }
4639            let pending = actual.pending_commit_ids();
4640            actual.overlay_rebuild_count.set(0);
4641            for (id, title) in [
4642                ("b", "available"),
4643                ("b", "occupied"),
4644                ("a", "server original"),
4645                ("d", "local value"),
4646                ("d", "released"),
4647                ("c", "redelivered"),
4648            ] {
4649                let table = actual.schema.table("tasks").unwrap().clone();
4650                let values = vec![
4651                    Some(ColumnValue::String(id.into())),
4652                    Some(ColumnValue::String("p1".into())),
4653                    Some(ColumnValue::String(title.into())),
4654                ];
4655                if images {
4656                    let image = Connection::open_in_memory().unwrap();
4657                    image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4658                        CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4659                        INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
4660                    image
4661                        .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
4662                        .unwrap();
4663                    assert!(matches!(
4664                        actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
4665                        Ok(1)
4666                    ));
4667                } else {
4668                    let segment = RowsSegment {
4669                        table: "tasks".into(),
4670                        schema_version: 1,
4671                        columns: table.wire_columns.clone(),
4672                        blocks: vec![vec![ssp2::segment::SegmentRow {
4673                            server_version: 2,
4674                            values: values.clone(),
4675                        }]],
4676                    };
4677                    assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
4678                }
4679                reference.write_base_row("tasks", &values, 2).unwrap();
4680                reference.rebuild_overlay();
4681                for table in ["tasks", "notes"] {
4682                    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")] {
4683                        assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
4684                    }
4685                }
4686                assert_eq!(actual.pending_commit_ids(), pending);
4687            }
4688            assert_eq!(
4689                actual.overlay_rebuild_count.get(),
4690                0,
4691                "each chunk must leave unrelated tables alone"
4692            );
4693            let before = actual
4694                .query("SELECT * FROM tasks ORDER BY id", &[])
4695                .unwrap();
4696            let fts_before = actual
4697                .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
4698                .unwrap();
4699            let revision = actual.local_revision();
4700            actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
4701            let table = actual.schema.table("tasks").unwrap().clone();
4702            let segment = RowsSegment {
4703                table: "tasks".into(),
4704                schema_version: 1,
4705                columns: table.wire_columns.clone(),
4706                blocks: vec![vec![ssp2::segment::SegmentRow {
4707                    server_version: 3,
4708                    values: vec![
4709                        Some(ColumnValue::String("b".into())),
4710                        Some(ColumnValue::String("p1".into())),
4711                        Some(ColumnValue::String("fresh".into())),
4712                    ],
4713                }]],
4714            };
4715            assert!(actual.apply_segment(0, &segment, false).is_err());
4716            assert!(actual.conn.is_autocommit());
4717            assert_eq!(
4718                actual
4719                    .query("SELECT * FROM tasks ORDER BY id", &[])
4720                    .unwrap(),
4721                before
4722            );
4723            assert_eq!(
4724                actual
4725                    .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
4726                    .unwrap(),
4727                fts_before
4728            );
4729            assert_eq!(actual.local_revision(), revision);
4730            assert_eq!(actual.pending_commit_ids(), pending);
4731        }
4732    }
4733
4734    #[test]
4735    fn import_reconciliation_preserves_typed_pending_primary_keys() {
4736        for (kind, a, b) in [
4737            ("string", json!("a"), json!("b")),
4738            ("integer", json!(1), json!(2)),
4739            ("boolean", json!(true), json!(false)),
4740            ("json", json!("1"), json!("2")),
4741        ] {
4742            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}]}]});
4743            for images in [false, true] {
4744                let mut client =
4745                    SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
4746                let table = client.schema.table("tasks").unwrap().clone();
4747                client
4748                    .subscribe("tasks".into(), "tasks".into(), vec![], None)
4749                    .unwrap();
4750                for (id, title) in [(&a, "original"), (&b, "occupied")] {
4751                    client
4752                        .write_base_row(
4753                            "tasks",
4754                            &vec![
4755                                json_to_column_value(&table.columns[0], Some(id)).unwrap(),
4756                                Some(ColumnValue::String(title.into())),
4757                            ],
4758                            1,
4759                        )
4760                        .unwrap();
4761                }
4762                client.rebuild_overlay_if_dirty();
4763                client
4764                    .mutate(vec![Mutation::Upsert {
4765                        table: "tasks".into(),
4766                        values: Map::from_iter([
4767                            ("id".into(), a.clone()),
4768                            ("title".into(), json!("available")),
4769                        ]),
4770                        base_version: None,
4771                    }])
4772                    .unwrap();
4773                client.overlay_rebuild_count.set(0);
4774                for (title, expected) in [("available", "original"), ("occupied", "available")] {
4775                    let values = vec![
4776                        json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
4777                        Some(ColumnValue::String(title.into())),
4778                    ];
4779                    if images {
4780                        let image = Connection::open_in_memory().unwrap();
4781                        image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4782                            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4783                            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
4784                        image
4785                            .execute(
4786                                "INSERT INTO tasks VALUES (?1,?2,2)",
4787                                rusqlite::params![RowParam::Cell(&values[0]), title],
4788                            )
4789                            .unwrap();
4790                        assert!(
4791                            matches!(
4792                                client.apply_sqlite_image(
4793                                    &image,
4794                                    &table,
4795                                    false,
4796                                    &[],
4797                                    1,
4798                                    7,
4799                                    "digest"
4800                                ),
4801                                Ok(1)
4802                            ),
4803                            "{kind}, images={images}"
4804                        );
4805                    } else {
4806                        let segment = RowsSegment {
4807                            table: "tasks".into(),
4808                            schema_version: 1,
4809                            columns: table.wire_columns.clone(),
4810                            blocks: vec![vec![ssp2::segment::SegmentRow {
4811                                server_version: 2,
4812                                values,
4813                            }]],
4814                        };
4815                        assert!(
4816                            matches!(client.apply_segment(0, &segment, false), Ok(1)),
4817                            "{kind}"
4818                        );
4819                    }
4820                    let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
4821                    let actual: String = client
4822                        .conn
4823                        .query_row(
4824                            "SELECT title FROM tasks WHERE id=?1",
4825                            [RowParam::Cell(&key)],
4826                            |row| row.get(0),
4827                        )
4828                        .unwrap();
4829                    assert_eq!(actual, expected, "{kind}, images={images}");
4830                }
4831                assert_eq!(client.overlay_rebuild_count.get(), 0);
4832            }
4833        }
4834    }
4835
4836    fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
4837        let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
4838            "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
4839            "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
4840            "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
4841        })))});
4842        let mut client =
4843            SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
4844        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();
4845        client.rebuild_overlay();
4846        client
4847            .mutate(vec![Mutation::Upsert {
4848                table: "tasks".into(),
4849                values: Map::from_iter([
4850                    ("id".into(), json!("offline")),
4851                    ("project_id".into(), json!("p1")),
4852                    ("title".into(), json!("pending edit")),
4853                ]),
4854                base_version: None,
4855            }])
4856            .unwrap();
4857        let image = Connection::open_in_memory().unwrap();
4858        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4859            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
4860        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();
4861        image
4862            .execute(
4863                "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
4864                [rows],
4865            )
4866            .unwrap();
4867        client.overlay_rebuild_count.set(0);
4868        let table = client.schema.table("tasks").unwrap().clone();
4869        (client, image, table)
4870    }
4871
4872    #[test]
4873    fn sqlite_full_image_import_retains_the_first_code_and_recovers() {
4874        let (mut client, image, table) = unique_import_fixture(1);
4875        image
4876            .execute("UPDATE tasks SET title = ?1", ["full ".repeat(32768)])
4877            .unwrap();
4878        client
4879            .conn
4880            .execute_batch("PRAGMA max_page_count = 1")
4881            .unwrap();
4882        let effective = [("project_id".into(), vec!["p1".into()])];
4883        assert!(client
4884            .apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest")
4885            .is_err());
4886        let failure = client.storage_failure.borrow().clone().unwrap();
4887        assert_eq!(failure.code, Some("client.storage_full"));
4888        assert_eq!(failure.sqlite_code, Some(13));
4889        assert!(!failure.message.contains("rollback"));
4890        assert!(client.conn.is_autocommit());
4891        client
4892            .conn
4893            .execute_batch("PRAGMA max_page_count = 1073741823")
4894            .unwrap();
4895        assert!(matches!(
4896            client.apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest"),
4897            Ok(1)
4898        ));
4899        assert!(client.conn.is_autocommit());
4900    }
4901
4902    #[test]
4903    fn large_unique_import_copies_no_unrelated_tables() {
4904        let (mut client, image, table) = unique_import_fixture(10240);
4905        let pending = client.pending_commit_ids();
4906        client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
4907            CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
4908        assert!(matches!(
4909            client.apply_sqlite_image(
4910                &image,
4911                &table,
4912                true,
4913                &[("project_id".into(), vec!["p1".into()])],
4914                10240,
4915                7,
4916                "digest"
4917            ),
4918            Ok(10240)
4919        ));
4920        assert_eq!(client.overlay_rebuild_count.get(), 0);
4921        assert_eq!(client.pending_commit_ids(), pending);
4922        assert_eq!(
4923            client
4924                .query("SELECT count(*) AS n FROM tasks", &[])
4925                .unwrap()[0]["n"],
4926            10241
4927        );
4928        assert_eq!(
4929            client
4930                .query("SELECT count(*) AS n FROM tasks_fts", &[])
4931                .unwrap()[0]["n"],
4932            10241
4933        );
4934        assert_eq!(
4935            client
4936                .query("SELECT count(*) AS n FROM notes_fts", &[])
4937                .unwrap()[0]["n"],
4938            8192
4939        );
4940    }
4941
4942    #[test]
4943    #[ignore = "manual scaling benchmark; see bench/README.md"]
4944    fn benchmark_unique_image_import() {
4945        let mut samples = Vec::new();
4946        for rows in [10240, 51200, 102400] {
4947            for trial in 0..4 {
4948                let (mut client, image, table) = unique_import_fixture(rows);
4949                let start = std::time::Instant::now();
4950                assert!(
4951                    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)
4952                );
4953                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
4954                assert_eq!(
4955                    client
4956                        .query("SELECT count(*) AS n FROM tasks", &[])
4957                        .unwrap()[0]["n"],
4958                    rows + 1
4959                );
4960                assert_eq!(
4961                    client
4962                        .query("SELECT count(*) AS n FROM tasks_fts", &[])
4963                        .unwrap()[0]["n"],
4964                    rows + 1
4965                );
4966                assert_eq!(
4967                    client
4968                        .query("SELECT count(*) AS n FROM notes_fts", &[])
4969                        .unwrap()[0]["n"],
4970                    8192
4971                );
4972                assert_eq!(client.pending_commit_ids().len(), 1);
4973                if trial > 0 {
4974                    samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
4975                }
4976            }
4977        }
4978        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});
4979        let output = serde_json::to_string_pretty(&artifact).unwrap();
4980        if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
4981            use std::io::Write;
4982            let mut file = std::fs::OpenOptions::new()
4983                .write(true)
4984                .create_new(true)
4985                .open(path)
4986                .unwrap();
4987            file.write_all(output.as_bytes()).unwrap();
4988        } else {
4989            println!("{output}");
4990        }
4991    }
4992
4993    #[test]
4994    fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
4995        let schema = json!({
4996            "version": 1,
4997            "tables": [{
4998                "name": "catalogue_codes",
4999                "primaryKey": "id",
5000                "columns": [
5001                    { "name": "id", "type": "string", "nullable": false },
5002                    { "name": "release_id", "type": "string", "nullable": false },
5003                    { "name": "code", "type": "string", "nullable": false },
5004                    { "name": "title", "type": "string", "nullable": false }
5005                ],
5006                "scopes": [{ "pattern": "release:{release_id}" }],
5007                "ftsIndexes": [{
5008                    "name": "catalogue_codes_fts",
5009                    "columns": ["code", "title"],
5010                    "tokenize": "unicode61 remove_diacritics 2"
5011                }]
5012            }]
5013        });
5014        let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
5015            .expect("FTS5 client");
5016        let insert_trigger: String = client
5017            .conn
5018            .query_row(
5019                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
5020                [],
5021                |row| row.get(0),
5022            )
5023            .expect("insert trigger");
5024        let replace_guard: String = client
5025            .conn
5026            .query_row(
5027                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
5028                [],
5029                |row| row.get(0),
5030            )
5031            .expect("replace guard");
5032        assert!(!insert_trigger.contains("DELETE FROM"));
5033        assert!(replace_guard.contains("BEFORE INSERT"));
5034        assert!(replace_guard.contains("WHEN EXISTS"));
5035        let search = |client: &SyncClient, query: &str| {
5036            client
5037                .query(
5038                    "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",
5039                    &[Value::from(query)],
5040                )
5041                .expect("FTS query")
5042        };
5043
5044        client
5045            .mutate(vec![Mutation::Upsert {
5046                table: "catalogue_codes".to_owned(),
5047                values: Map::from_iter([
5048                    ("id".to_owned(), Value::from("c1")),
5049                    ("release_id".to_owned(), Value::from("r1")),
5050                    ("code".to_owned(), Value::from("A01")),
5051                    ("title".to_owned(), Value::from("Cholera")),
5052                ]),
5053                base_version: None,
5054            }])
5055            .expect("insert code");
5056        assert_eq!(search(&client, "cholera").len(), 1);
5057        let prior = client
5058            .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
5059            .unwrap();
5060        client
5061            .conn
5062            .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
5063            .unwrap();
5064        client.create_synced_tables().unwrap();
5065        assert_eq!(
5066            client
5067                .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
5068                .unwrap(),
5069            prior
5070        );
5071        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();
5072        let rendered = serde_json::to_string(&plan).unwrap();
5073        assert!(rendered.contains("COVERING INDEX"));
5074        assert!(rendered.contains("INDEX 0:="));
5075        client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
5076        assert_eq!(search(&client, "cholera").len(), 1);
5077
5078        client
5079            .mutate(vec![Mutation::Upsert {
5080                table: "catalogue_codes".to_owned(),
5081                values: Map::from_iter([
5082                    ("id".to_owned(), Value::from("c1")),
5083                    ("release_id".to_owned(), Value::from("r1")),
5084                    ("code".to_owned(), Value::from("A01")),
5085                    ("title".to_owned(), Value::from("Enteric infection")),
5086                ]),
5087                base_version: None,
5088            }])
5089            .expect("update code");
5090        assert!(search(&client, "cholera").is_empty());
5091        assert_eq!(search(&client, "enteric").len(), 1);
5092
5093        client
5094            .mutate(vec![Mutation::Delete {
5095                table: "catalogue_codes".to_owned(),
5096                row_id: "c1".to_owned(),
5097                base_version: None,
5098            }])
5099            .expect("delete code");
5100        assert!(search(&client, "enteric").is_empty());
5101    }
5102
5103    #[test]
5104    fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
5105        let schema = json!({
5106            "version": 1,
5107            "tables": [{
5108                "name": "patient_notes",
5109                "primaryKey": "id",
5110                "columns": [
5111                    { "name": "id", "type": "string", "nullable": false },
5112                    { "name": "practice_id", "type": "string", "nullable": false },
5113                    { "name": "encryption_key_id", "type": "string", "nullable": false },
5114                    { "name": "title", "type": "string", "nullable": false }
5115                ],
5116                "scopes": [{ "pattern": "practice:{practice_id}" }],
5117                "ftsIndexes": [{
5118                    "name": "patient_notes_fts",
5119                    "columns": ["title"],
5120                    "tokenize": "unicode61 remove_diacritics 2"
5121                }]
5122            }]
5123        });
5124        let mut client = SyncClient::new(
5125            "local-purge-test".to_owned(),
5126            &schema,
5127            ClientLimits::default(),
5128        )
5129        .expect("local purge client");
5130        client
5131            .conn
5132            .execute(
5133                "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
5134                 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
5135                 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
5136                [],
5137            )
5138            .expect("seed base rows");
5139        client.overlay_dirty.set(true);
5140        client.rebuild_overlay_if_dirty();
5141
5142        let note = |id: &str, key_id: &str, title: &str| {
5143            Map::from_iter([
5144                ("id".to_owned(), Value::from(id)),
5145                ("practice_id".to_owned(), Value::from("practice-1")),
5146                ("encryption_key_id".to_owned(), Value::from(key_id)),
5147                ("title".to_owned(), Value::from(title)),
5148            ])
5149        };
5150        let doomed = client
5151            .mutate(vec![
5152                Mutation::Upsert {
5153                    table: "patient_notes".to_owned(),
5154                    values: note("target", "key-revoked", "Target changed"),
5155                    base_version: None,
5156                },
5157                Mutation::Upsert {
5158                    table: "patient_notes".to_owned(),
5159                    values: note("unrelated", "key-held", "Unrelated changed"),
5160                    base_version: None,
5161                },
5162            ])
5163            .expect("doomed commit");
5164        let kept = client
5165            .mutate(vec![Mutation::Upsert {
5166                table: "patient_notes".to_owned(),
5167                values: note("kept", "key-held", "Kept optimistic"),
5168                base_version: None,
5169            }])
5170            .expect("kept commit");
5171        client.drain_change_batches();
5172
5173        let input = LocalDataPurgeInput {
5174            purge_id: "purge-001".to_owned(),
5175            targets: vec![LocalDataPurgeTarget {
5176                table: "patient_notes".to_owned(),
5177                selectors: BTreeMap::from([(
5178                    "encryption_key_id".to_owned(),
5179                    vec!["key-revoked".to_owned()],
5180                )]),
5181            }],
5182        };
5183        assert_eq!(
5184            client.purge_local_data(&input).expect("apply purge"),
5185            LocalDataPurgeResult {
5186                already_applied: false,
5187                purged_rows: 1,
5188                dropped_commits: 1,
5189            }
5190        );
5191        let rows = client
5192            .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
5193            .expect("visible rows");
5194        assert_eq!(rows.len(), 2);
5195        assert_eq!(rows[0]["id"], "kept");
5196        assert_eq!(rows[1]["id"], "unrelated");
5197        assert_eq!(rows[1]["title"], "Unrelated original");
5198        let fts = client
5199            .query(
5200                "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'",
5201                &[],
5202            )
5203            .expect("fts query");
5204        assert!(fts.is_empty());
5205        assert_eq!(client.outbox.len(), 1);
5206        assert_eq!(client.outbox[0].client_commit_id, kept);
5207        let outcome = client
5208            .commit_outcome(&doomed)
5209            .expect("read doomed outcome")
5210            .expect("doomed outcome");
5211        assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
5212        match &outcome.results[0] {
5213            CommitOperationOutcome::Error { rejection } => {
5214                assert_eq!(rejection.code, "client.local_data_purged");
5215                assert_eq!(rejection.client_commit_id, doomed);
5216            }
5217            other => panic!("expected local purge rejection, got {other:?}"),
5218        }
5219        assert_eq!(client.drain_change_batches().len(), 1);
5220        assert_eq!(
5221            client.purge_local_data(&input).expect("retry purge"),
5222            LocalDataPurgeResult {
5223                already_applied: true,
5224                purged_rows: 0,
5225                dropped_commits: 0,
5226            }
5227        );
5228        let conflicting = LocalDataPurgeInput {
5229            purge_id: input.purge_id.clone(),
5230            targets: vec![LocalDataPurgeTarget {
5231                table: "patient_notes".to_owned(),
5232                selectors: BTreeMap::from([(
5233                    "encryption_key_id".to_owned(),
5234                    vec!["key-held".to_owned()],
5235                )]),
5236            }],
5237        };
5238        assert!(client
5239            .purge_local_data(&conflicting)
5240            .expect_err("id collision must fail")
5241            .contains("already used with a different plan"));
5242    }
5243}
5244
5245impl SubState {
5246    fn name(self) -> &'static str {
5247        match self {
5248            SubState::Active => "active",
5249            SubState::Revoked => "revoked",
5250            SubState::Failed => "failed",
5251        }
5252    }
5253
5254    fn parse(value: &str) -> Self {
5255        match value {
5256            "revoked" => Self::Revoked,
5257            "failed" => Self::Failed,
5258            _ => Self::Active,
5259        }
5260    }
5261}
5262
5263#[derive(Debug, Clone)]
5264struct Subscription {
5265    id: String,
5266    table: String,
5267    requested: Vec<(String, Vec<String>)>,
5268    params: Option<String>,
5269    cursor: i64,
5270    /// §4.7 resume token, round-tripped opaquely.
5271    bootstrap_state: Option<String>,
5272    state: SubState,
5273    reason_code: Option<String>,
5274    /// Last effective scopes echoed while active (§3.3: persisted for the
5275    /// purge contract; each active echo replaces it).
5276    effective: Option<Vec<(String, Vec<String>)>>,
5277    synced_once: bool,
5278}
5279
5280#[derive(Debug, Clone)]
5281struct OutboxOp {
5282    upsert: bool,
5283    table: String,
5284    row_id: String,
5285    base_version: Option<i64>,
5286    /// Schema-agnostic local form (§0): driver JSON values, encoded with
5287    /// the current codec at send time. A key's presence IS the §6.1 sparse
5288    /// presence set — both the overlay and the wire payload derive from it.
5289    values: Option<Map<String, Value>>,
5290}
5291
5292impl From<&OutboxOp> for CommitOperation {
5293    fn from(operation: &OutboxOp) -> Self {
5294        Self {
5295            table: operation.table.clone(),
5296            row_id: operation.row_id.clone(),
5297            op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
5298            base_version: operation.base_version,
5299            values: operation.values.clone(),
5300        }
5301    }
5302}
5303
5304#[derive(Debug, Clone)]
5305struct OutboxCommit {
5306    client_commit_id: String,
5307    ops: Vec<OutboxOp>,
5308}
5309
5310/// One commit's `(table, value keys)` per upsert operation — the classification
5311/// shape the §7.4.4 send-time drop and the RFC 0005 D6 audit share, so the two
5312/// can never disagree about which commits cannot re-encode.
5313fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>)> {
5314    commit
5315        .ops
5316        .iter()
5317        .filter(|operation| operation.upsert)
5318        .map(|operation| {
5319            (
5320                operation.table.as_str(),
5321                operation
5322                    .values
5323                    .as_ref()
5324                    .map(|values| values.keys().map(String::as_str).collect())
5325                    .unwrap_or_default(),
5326            )
5327        })
5328        .collect()
5329}
5330
5331type FailedCommit = (OutboxCommit, Map<String, Value>);
5332
5333#[derive(Debug, Clone)]
5334struct CompiledLocalDataPurgeTarget {
5335    table: String,
5336    selectors: Vec<(String, Vec<String>)>,
5337}
5338
5339struct StoredCommitOutcomeRow {
5340    sequence: i64,
5341    client_commit_id: String,
5342    status: String,
5343    recorded_at_ms: i64,
5344    results_json: String,
5345    operations_json: Option<String>,
5346    resolution: String,
5347    resolved_at_ms: Option<i64>,
5348    replacement_client_commit_id: Option<String>,
5349}
5350
5351/// Section outcome distinguishing the §5.6 subscription-local fail-closed
5352/// path from a round-aborting failure (§1.4 rule 5).
5353enum SectionError {
5354    FailClosed,
5355    Abort(String, String),
5356}
5357
5358pub(crate) struct RequestMeta {
5359    pushed_ids: Vec<String>,
5360    /// Subscription id → the request carried `cursor < 0` and no resume
5361    /// token (§5.6 first-page detection: a *fresh* bootstrap).
5362    fresh: Vec<(String, bool)>,
5363    pub(crate) accept: u8,
5364    /// §6.1 splitBatch: outbox commits held back from THIS request because
5365    /// the running operation count reached the push cap — the next round
5366    /// pushes them (`sync_needed` stays set while any remain).
5367    deferred_commits: usize,
5368}
5369
5370#[derive(Default)]
5371struct ChangeAccumulator {
5372    /// None means table-wide; Some is the exact scoped domain.
5373    tables: BTreeMap<String, Option<BTreeSet<String>>>,
5374    windows: BTreeMap<(String, String), BTreeSet<String>>,
5375    status: bool,
5376    conflicts: bool,
5377    rejections: bool,
5378    outcomes: bool,
5379}
5380
5381impl ChangeAccumulator {
5382    fn table(&mut self, table: &str) {
5383        self.tables.insert(table.to_owned(), None);
5384    }
5385
5386    fn scope(&mut self, table: &str, key: String) {
5387        match self.tables.get_mut(table) {
5388            Some(None) => {}
5389            Some(Some(keys)) => {
5390                keys.insert(key);
5391            }
5392            None => {
5393                self.tables
5394                    .insert(table.to_owned(), Some(BTreeSet::from([key])));
5395            }
5396        }
5397    }
5398
5399    fn window(&mut self, base_key: &str, table: &str, unit: &str) {
5400        self.windows
5401            .entry((base_key.to_owned(), table.to_owned()))
5402            .or_default()
5403            .insert(unit.to_owned());
5404    }
5405
5406    fn touched(&self) -> bool {
5407        !self.tables.is_empty()
5408            || !self.windows.is_empty()
5409            || self.status
5410            || self.conflicts
5411            || self.rejections
5412            || self.outcomes
5413    }
5414}
5415
5416/// §6.1: the server caps total operations per request (reference default
5417/// 500) and rejects the whole batch with `sync.too_many_operations`; the
5418/// client "splits and retries". Splitting happens at build time: commits are
5419/// included IN ORDER until the operation budget is spent, the rest wait for
5420/// the next round.
5421const PUSH_OPS_PER_REQUEST: usize = 500;
5422const MAX_LOCAL_PURGE_TARGETS: usize = 64;
5423const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
5424const MAX_LOCAL_PURGE_VALUES: usize = 128;
5425const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
5426pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
5427
5428/// Dirty visible tables; `None` means a whole-replica recovery/reset.
5429struct OverlayDirty(RefCell<Option<BTreeSet<String>>>);
5430
5431impl OverlayDirty {
5432    fn clean() -> Self {
5433        Self(RefCell::new(Some(BTreeSet::new())))
5434    }
5435    fn get(&self) -> bool {
5436        self.0
5437            .borrow()
5438            .as_ref()
5439            .is_none_or(|tables| !tables.is_empty())
5440    }
5441    fn set(&self, dirty: bool) {
5442        *self.0.borrow_mut() = if dirty { None } else { Some(BTreeSet::new()) };
5443    }
5444    fn table(&self, table: &str) {
5445        if let Some(tables) = self.0.borrow_mut().as_mut() {
5446            tables.insert(table.to_owned());
5447        }
5448    }
5449    fn snapshot(&self) -> Option<BTreeSet<String>> {
5450        self.0.borrow().clone()
5451    }
5452    fn restore(&self, tables: Option<BTreeSet<String>>) {
5453        *self.0.borrow_mut() = tables;
5454    }
5455}
5456
5457pub struct SyncClient {
5458    #[cfg(feature = "bench-internals")]
5459    benchmark_phases: Recorder,
5460    conn: Connection,
5461    schema: ClientSchema,
5462    client_id: String,
5463    limits: ClientLimits,
5464    subs: Vec<Subscription>,
5465    outbox: Vec<OutboxCommit>,
5466    failed_commits: Vec<FailedCommit>,
5467    retain_failed_commits: bool,
5468    conflicts: Vec<ConflictRecord>,
5469    rejections: Vec<RejectionRecord>,
5470    schema_floor: Option<SchemaFloor>,
5471    /// §7.3.5: the opaque auth-lease state (from LEASE frames + lease errors).
5472    lease_state: Option<LeaseState>,
5473    /// §1.6: the schema-floor response stops syncing until an upgrade.
5474    stopped: bool,
5475    /// §7.4.5: true while a schema-bump reset + first re-bootstrap is in flight.
5476    upgrading: bool,
5477    /// §8.4 coalesced sync-needed signal.
5478    sync_needed: bool,
5479    /// §8.8: how sync rounds treat the realtime binding, and its exact
5480    /// availability state with the evidence behind a lost or refused one.
5481    realtime_policy: RealtimePolicy,
5482    realtime_state: RealtimeState,
5483    realtime_reason_code: Option<String>,
5484    realtime_retry_delay_ms: Option<u64>,
5485    /// §8.6 presence: scopeKey → (`actorId clientId` peer key → peer).
5486    presence: HashMap<String, HashMap<String, PresencePeer>>,
5487    /// Client clock (epoch ms) for the §5.4 `urlExpiresAtMs` check; the
5488    /// host may pin it (conformance runs on a virtual clock).
5489    now_ms: Option<i64>,
5490    /// §5.11 client-side encryption keys (`keyId → key bytes`). Empty ⇒ E2EE
5491    /// off. The encrypt/decrypt seam (`values.rs`) is compiled only under the
5492    /// `e2ee` feature; without it, a schema with encrypted columns fails loud.
5493    encryption: crate::values::EncryptionConfig,
5494    /// Fail-closed host bootstrap gate. While set, command hosts permit only
5495    /// status/lifecycle inspection and an exact authorized local purge.
5496    security_preflight: bool,
5497    transport_enabled: bool,
5498    /// RFC 0005 D2/D8: the previous-version capture config. `None` (default)
5499    /// means the feature is off; the descriptor write and the container orphan
5500    /// sweep still happen.
5501    previous_version: Option<PreviousVersionContextConfig>,
5502    /// Per-table primary-key upsert SQL, built once per (full table name) —
5503    /// the row write path runs per row during bootstrap (§5.6), so the SQL
5504    /// string (and, via `prepare_cached`, its compiled statement) is reused
5505    /// instead of being rebuilt and re-prepared per row. Cleared on a §7.4.3
5506    /// schema reset (the column lists may have changed).
5507    insert_sql: RefCell<HashMap<String, String>>,
5508    /// §7.1: reconcile only tables whose base/outbox changed. Whole-replica
5509    /// reset and restart explicitly invalidate every table.
5510    overlay_dirty: OverlayDirty,
5511    // First SQLite failure of the current operation, before savepoint cleanup.
5512    storage_failure: RefCell<Option<QueryReadFailure>>,
5513    /// Test-only structural performance signal: response processing must not
5514    /// turn a batch of acknowledgements into one full overlay rebuild each.
5515    #[cfg(test)]
5516    overlay_rebuild_count: Cell<usize>,
5517    /// Test-only structural performance signal: outcome retention is enforced
5518    /// once per response rather than once per acknowledgement.
5519    #[cfg(test)]
5520    outcome_prune_count: Cell<usize>,
5521    /// Exact observer-transaction output drained by command/FFI hosts.
5522    progress: ProgressObserver,
5523    change_queue: VecDeque<ClientChangeBatch>,
5524    sync_intent_queue: VecDeque<SyncIntent>,
5525    /// Explicit exponential retry policy for transient transport failures.
5526    retry_delay_ms: u64,
5527    /// §7.6: the background retry the current round scheduled, if any.
5528    round_retry_delay_ms: Option<u64>,
5529    active_round: Option<uuid::Uuid>,
5530    last_round: Option<DiagnosticLastRound>,
5531    last_change: Option<DiagnosticLastChange>,
5532    /// §7.6: latest failed owned snapshot read per owner id, oldest first.
5533    query_failures: Vec<DiagnosticQueryFailure>,
5534}
5535
5536pub(crate) fn quote_ident(name: &str) -> String {
5537    format!("\"{}\"", name.replace('"', "\"\""))
5538}
5539/// §7.4.1 `_syncular_meta` helpers. Free functions so RFC 0005's
5540/// previous-version module reads and writes the SAME records as the client
5541/// instead of growing a parallel meta layer.
5542pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
5543    conn.query_row(
5544        "SELECT value FROM _syncular_meta WHERE key = ?1",
5545        rusqlite::params![key],
5546        |row| row.get::<_, String>(0),
5547    )
5548    .ok()
5549}
5550
5551pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
5552    let _ = conn.execute(
5553        "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
5554        rusqlite::params![key, value],
5555    );
5556}
5557
5558pub(crate) fn meta_delete(conn: &Connection, key: &str) {
5559    let _ = conn.execute(
5560        "DELETE FROM _syncular_meta WHERE key = ?1",
5561        rusqlite::params![key],
5562    );
5563}
5564
5565/// Seek by the existing key while retaining the exact legacy text match.
5566fn row_id_predicate(table: &TableSchema) -> String {
5567    let key = quote_ident(&table.primary_key);
5568    let text_match = format!("CAST({key} AS TEXT) = ?1");
5569    match table.columns[table.pk_index].ty {
5570        ColumnType::String | ColumnType::Json => {
5571            format!("{key} = ?1 AND {text_match}")
5572        }
5573        // Native columns have no declared SQLite affinity. Match the stored
5574        // integer bind type explicitly. Unary + removes expression affinity so
5575        // SQLite can seek the typeless key; the text check preserves exact IDs.
5576        ColumnType::Integer | ColumnType::Boolean => {
5577            format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
5578        }
5579        // REAL-to-text conversion can round distinct values to the same text.
5580        // Narrowing those matches by numeric equality would change behavior.
5581        _ => text_match,
5582    }
5583}
5584
5585fn is_local_operation_code_like(value: &str) -> bool {
5586    let bytes = value.as_bytes();
5587    bytes.first().is_some_and(u8::is_ascii_alphanumeric)
5588        && bytes
5589            .iter()
5590            .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
5591}
5592
5593fn base_table(name: &str) -> String {
5594    quote_ident(&format!("_syncular_base_{name}"))
5595}
5596
5597/// §4.8: a stable, server-opaque key for a window base — table + variable +
5598/// canonical fixed scopes. Two `set_window` calls with the same base
5599/// address the same registry rows.
5600/// §4.8 deferred eviction record: (sub id, table, effective scope map).
5601type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
5602
5603fn window_base_key(base: &WindowBase) -> String {
5604    format!(
5605        "{}\0{}\0{}",
5606        base.table,
5607        base.variable,
5608        canonical_scope_json(&base.fixed_scopes)
5609    )
5610}
5611
5612/// §4.8: the full requested scope map for one unit (fixed scopes + unit).
5613fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
5614    let mut scopes = base.fixed_scopes.clone();
5615    scopes.retain(|(k, _)| k != &base.variable);
5616    scopes.push((base.variable.clone(), vec![unit.to_owned()]));
5617    scopes
5618}
5619
5620/// §4.1 guidance: `w:<table>:<sha256(canonical scope map)[0..16]>`. Ids are
5621/// echoed not interpreted by the server, so the exact hash is client
5622/// convention; SHA-256 matches the SPEC's worked example.
5623fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
5624    let canonical = canonical_scope_json(&unit_scopes(base, unit));
5625    let digest = Sha256::digest(canonical.as_bytes());
5626    let hex = bytes_to_hex(&digest);
5627    format!("w:{}:{}", base.table, &hex[..16])
5628}
5629
5630fn visible_table(name: &str) -> String {
5631    quote_ident(name)
5632}
5633
5634const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
5635
5636/// §7.4.3: is a `sqlite_master` table name a synced table (visible or base),
5637/// i.e. NOT one of the durable bookkeeping tables the reset preserves?
5638fn is_synced_table_name(name: &str) -> bool {
5639    if name.starts_with("sqlite_") {
5640        return false;
5641    }
5642    if name.starts_with("_syncular_base_") {
5643        return true; // the base half of a synced table pair
5644    }
5645    // Bookkeeping: outbox, subscriptions, meta, blob cache/uploads.
5646    !name.starts_with("_syncular_")
5647}
5648
5649/// `"sha256:" + hex` of the bytes — the content address (§5.9.1).
5650fn blob_id_for(bytes: &[u8]) -> String {
5651    let digest = Sha256::digest(bytes);
5652    format!("sha256:{}", bytes_to_hex(&digest))
5653}
5654
5655/// One [`SyncClient::write_row`] bind parameter, borrowing the row-codec
5656/// value it wraps — strings/JSON/bytes bind as borrowed TEXT/BLOB (no copy
5657/// per row on the §5.6 bootstrap path), scalars bind owned.
5658enum RowParam<'a> {
5659    Cell(&'a Option<ColumnValue>),
5660    Version(i64),
5661}
5662
5663impl rusqlite::ToSql for RowParam<'_> {
5664    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
5665        Ok(match self {
5666            RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
5667            RowParam::Cell(cell) => match cell {
5668                None => ToSqlOutput::Owned(SqlValue::Null),
5669                Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
5670                Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
5671                Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
5672                Some(ColumnValue::Boolean(b)) => {
5673                    ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
5674                }
5675                Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
5676                    ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
5677                }
5678                // §5.10: crdt bytes store as BLOB, like bytes.
5679                Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
5680                    ToSqlOutput::Borrowed(ValueRef::Blob(b))
5681                }
5682            },
5683        })
5684    }
5685}
5686
5687// Retain a typed primary key across the current import chunk without copying row payloads.
5688fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
5689    match param.to_sql().map_err(|error| error.to_string())? {
5690        ToSqlOutput::Owned(value) => Ok(value),
5691        ToSqlOutput::Borrowed(value) => {
5692            SqlValue::try_from(value).map_err(|error| error.to_string())
5693        }
5694        _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
5695    }
5696}
5697
5698/// §5.3 image cell → bind parameter, strict per the declared column type
5699/// (`boolean` from INTEGER 0/1, `json` from its raw TEXT, NULL only when
5700/// nullable). Mismatches are image-producer violations. Returns the cell
5701/// borrowed when the stored representation already matches what the row
5702/// codec would write, or the normalized scalar (boolean → 0/1, float from
5703/// INTEGER → REAL) otherwise — the local write is byte-identical to the
5704/// old convert-then-insert path without allocating per cell.
5705fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
5706    use ssp2::segment::ColumnType;
5707    let mismatch = || {
5708        Err(format!(
5709            "image column {:?} holds a value of the wrong type",
5710            column.name
5711        ))
5712    };
5713    match value {
5714        ValueRef::Null => {
5715            if !column.nullable {
5716                return Err(format!(
5717                    "image column {:?} is NULL but not nullable",
5718                    column.name
5719                ));
5720            }
5721            Ok(ToSqlOutput::Owned(SqlValue::Null))
5722        }
5723        ValueRef::Integer(i) => match column.ty {
5724            ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
5725            ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
5726            ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
5727            _ => mismatch(),
5728        },
5729        ValueRef::Real(_) => match column.ty {
5730            ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
5731            _ => mismatch(),
5732        },
5733        ValueRef::Text(t) => {
5734            std::str::from_utf8(t)
5735                .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
5736            match column.ty {
5737                ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
5738                    Ok(ToSqlOutput::Borrowed(value))
5739                }
5740                _ => mismatch(),
5741            }
5742        }
5743        ValueRef::Blob(_) => match column.ty {
5744            // §5.10: a crdt column stores its opaque bytes as BLOB, like bytes.
5745            ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
5746            _ => mismatch(),
5747        },
5748    }
5749}
5750
5751pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
5752    use rusqlite::types::ValueRef;
5753    match value {
5754        ValueRef::Null => Value::Null,
5755        ValueRef::Integer(i) => match column.ty {
5756            ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
5757            ssp2::segment::ColumnType::Float => {
5758                serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
5759            }
5760            _ => Value::from(i),
5761        },
5762        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
5763        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
5764        ValueRef::Blob(b) => {
5765            let mut map = Map::new();
5766            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
5767            Value::Object(map)
5768        }
5769    }
5770}
5771
5772/// Bind a driver JSON value form as a rusqlite parameter for [`SyncClient::query`].
5773/// Objects are accepted in lossless `{"$bytes": hex}` and
5774/// `{"$bigint": decimal}` envelope forms.
5775fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
5776    Ok(match value {
5777        Value::Null => SqlValue::Null,
5778        Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
5779        Value::Number(n) => {
5780            if let Some(i) = n.as_i64() {
5781                SqlValue::Integer(i)
5782            } else if let Some(f) = n.as_f64() {
5783                SqlValue::Real(f)
5784            } else {
5785                return Err(format!("query param number {n} is out of range"));
5786            }
5787        }
5788        Value::String(s) => SqlValue::Text(s.clone()),
5789        Value::Object(_) => {
5790            if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
5791                SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
5792            } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
5793                SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
5794                    format!("query bigint param {decimal:?} is outside SQLite's i64 range")
5795                })?)
5796            } else {
5797                return Err(
5798                    "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
5799                        .to_owned(),
5800                );
5801            }
5802        }
5803        Value::Array(_) => return Err("query array params are not supported".to_owned()),
5804    })
5805}
5806
5807/// Map a rusqlite value with no schema column to consult (arbitrary query
5808/// output): integers/reals/text pass through by stored affinity, blobs ride
5809/// as `{"$bytes": hex}`. Distinct from [`sql_ref_to_json`], which uses the
5810/// schema column type to recover booleans/floats/json.
5811fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
5812    use rusqlite::types::ValueRef;
5813    match value {
5814        ValueRef::Null => Value::Null,
5815        ValueRef::Integer(i) => {
5816            // JSON/Tauri IPC cannot represent every SQLite i64 exactly. Keep
5817            // ordinary UI-sized integers ergonomic and envelope only values
5818            // beyond JavaScript's safe range.
5819            const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
5820            if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
5821                Value::from(i)
5822            } else {
5823                let mut map = Map::new();
5824                map.insert("$bigint".to_owned(), Value::from(i.to_string()));
5825                Value::Object(map)
5826            }
5827        }
5828        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
5829        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
5830        ValueRef::Blob(b) => {
5831            let mut map = Map::new();
5832            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
5833            Value::Object(map)
5834        }
5835    }
5836}
5837
5838/// §7.5: SQLite failures classify by result code into [`QueryReadFailure`].
5839fn query_connection(
5840    conn: &Connection,
5841    sql: &str,
5842    params: &[QueryValue],
5843) -> Result<Vec<QueryRow>, QueryReadFailure> {
5844    crate::query_guard::assert_read_only_query(sql)?;
5845    let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
5846    let bound: Vec<SqlValue> = params
5847        .iter()
5848        .map(json_param_to_sql)
5849        .collect::<Result<_, _>>()?;
5850    let mut stmt = conn.prepare(&lowered_sql)?;
5851    let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
5852    let bound_refs: Vec<&dyn rusqlite::ToSql> =
5853        bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
5854    let mut sql_rows = stmt.query(bound_refs.as_slice())?;
5855    let mut out = Vec::new();
5856    while let Some(row) = sql_rows.next()? {
5857        let mut record = Map::new();
5858        for (i, name) in column_names.iter().enumerate() {
5859            let value = row.get_ref(i)?;
5860            record.insert(name.clone(), sql_ref_to_json_dynamic(value));
5861        }
5862        out.push(record);
5863    }
5864    Ok(out)
5865}
5866
5867fn persisted_window_state(
5868    conn: &Connection,
5869    base: &WindowBase,
5870) -> Result<WindowState, QueryReadFailure> {
5871    let mut stmt = conn.prepare(
5872        "SELECT windows.unit, subscriptions.state_json
5873           FROM _syncular_windows AS windows
5874           JOIN _syncular_subscriptions AS subscriptions
5875             ON subscriptions.id = windows.sub_id
5876          WHERE windows.base = ?1
5877          ORDER BY windows.unit ASC",
5878    )?;
5879    let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
5880        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5881    })?;
5882    let mut units = Vec::new();
5883    let mut pending = Vec::new();
5884    for row in rows {
5885        let (unit, raw) = row?;
5886        let state: Value = serde_json::from_str(&raw)
5887            .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
5888        let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
5889            || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
5890            || state
5891                .get("bootstrapState")
5892                .is_some_and(|value| !value.is_null());
5893        if is_pending {
5894            pending.push(unit.clone());
5895        }
5896        units.push(unit);
5897    }
5898    Ok(WindowState { units, pending })
5899}
5900
5901fn snapshot_connection(
5902    conn: &Connection,
5903    sql: &str,
5904    params: &[Value],
5905    coverage: &[WindowCoverage],
5906) -> Result<QuerySnapshot, QueryReadFailure> {
5907    conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
5908    let result: Result<QuerySnapshot, QueryReadFailure> = (|| {
5909        let revision = conn
5910            .query_row(
5911                "SELECT value FROM _syncular_meta WHERE key = ?1",
5912                rusqlite::params![LOCAL_REVISION_KEY],
5913                |row| row.get::<_, String>(0),
5914            )
5915            .ok()
5916            .and_then(|value| value.parse::<u64>().ok())
5917            .unwrap_or(0);
5918        let rows = query_connection(conn, sql, params)?;
5919        let mut pending = Vec::new();
5920        let mut missing = Vec::new();
5921        for requested in coverage {
5922            let base_key = window_base_key(&requested.base);
5923            let state = persisted_window_state(conn, &requested.base)?;
5924            for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
5925                let reference = WindowUnitRef {
5926                    base_key: base_key.clone(),
5927                    unit: unit.clone(),
5928                };
5929                if !state.units.iter().any(|held| held == &unit) {
5930                    missing.push(reference);
5931                } else if state.pending.iter().any(|held| held == &unit) {
5932                    pending.push(reference);
5933                }
5934            }
5935        }
5936        Ok(QuerySnapshot {
5937            revision: revision.to_string(),
5938            rows,
5939            coverage: CoverageSnapshot {
5940                complete: pending.is_empty() && missing.is_empty(),
5941                pending,
5942                missing,
5943            },
5944        })
5945    })();
5946    match result {
5947        Ok(snapshot) => {
5948            conn.execute_batch("RELEASE syncular_snapshot_read")?;
5949            Ok(snapshot)
5950        }
5951        Err(mut error) => {
5952            if let Err(rollback) = conn
5953                .execute_batch("ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read")
5954            {
5955                error.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
5956            }
5957            Err(error)
5958        }
5959    }
5960}
5961
5962/// A long-lived read-only SQLite sidecar for latency-critical native views.
5963/// Network rounds stay serialized on the mutable core owner, while atomic
5964/// query snapshots use this independent connection and therefore never queue
5965/// behind HTTP/WebSocket latency.
5966pub struct FileQuerySnapshotReader {
5967    path: String,
5968    conn: Option<Connection>,
5969}
5970
5971impl FileQuerySnapshotReader {
5972    #[must_use]
5973    pub fn new(path: impl Into<String>) -> Self {
5974        Self {
5975            path: path.into(),
5976            conn: None,
5977        }
5978    }
5979
5980    fn connection(&mut self) -> Result<&Connection, String> {
5981        if self.conn.is_none() {
5982            let conn = Connection::open_with_flags(
5983                &self.path,
5984                OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
5985            )
5986            .map_err(|error| format!("open read sidecar {:?}: {error}", self.path))?;
5987            conn.busy_timeout(std::time::Duration::from_millis(250))
5988                .map_err(|error| error.to_string())?;
5989            self.conn = Some(conn);
5990        }
5991        self.conn
5992            .as_ref()
5993            .ok_or_else(|| "read sidecar connection missing".to_owned())
5994    }
5995
5996    /// Hosts report an owned failure, and the first success after it, to the
5997    /// owning core through [`SyncClient::record_query_read`] (§7.6).
5998    pub fn query_snapshot(
5999        &mut self,
6000        sql: &str,
6001        params: &[Value],
6002        coverage: &[WindowCoverage],
6003    ) -> Result<QuerySnapshot, QueryReadFailure> {
6004        snapshot_connection(self.connection()?, sql, params, coverage)
6005    }
6006}
6007
6008impl SyncClient {
6009    pub fn new_with_identity(
6010        client_id: Option<String>,
6011        schema_json: &Value,
6012        limits: ClientLimits,
6013    ) -> Result<Self, String> {
6014        let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
6015        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
6016        Self::with_connection(resolved, schema_json, limits, conn)
6017    }
6018
6019    pub fn new(
6020        client_id: String,
6021        schema_json: &Value,
6022        limits: ClientLimits,
6023    ) -> Result<Self, String> {
6024        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
6025        Self::with_connection(client_id, schema_json, limits, conn)
6026    }
6027
6028    /// Build a client backed by an on-disk SQLite database at `path` — the
6029    /// seam a native host (Tauri plugin, FFI file-DB variant) uses to persist
6030    /// across process restarts. `create_tables` runs `IF NOT EXISTS`, so
6031    /// re-opening the same file reuses the persisted rows. Keeps rusqlite out
6032    /// of the command router's dependency set (the router only holds a path).
6033    pub fn open_path(
6034        client_id: String,
6035        schema_json: &Value,
6036        limits: ClientLimits,
6037        path: &str,
6038    ) -> Result<Self, String> {
6039        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
6040        Self::with_connection(client_id, schema_json, limits, conn)
6041    }
6042
6043    pub fn open_path_with_identity(
6044        client_id: Option<String>,
6045        schema_json: &Value,
6046        limits: ClientLimits,
6047        path: &str,
6048    ) -> Result<Self, String> {
6049        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
6050        // File-backed native clients use an independent read connection for
6051        // latency-critical snapshots. WAL is SQLite's intended reader/writer
6052        // concurrency mode: a view read never holds a rollback-journal lock
6053        // that delays the mutable client's next commit, and a short busy
6054        // timeout absorbs the tiny checkpoint/schema-lock windows.
6055        conn.busy_timeout(std::time::Duration::from_millis(250))
6056            .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
6057        conn.pragma_update(None, "journal_mode", "WAL")
6058            .map_err(|error| format!("configure db {path:?} WAL mode: {error}"))?;
6059        let persisted = conn
6060            .query_row(
6061                "SELECT value FROM _syncular_meta WHERE key = 'clientId'",
6062                [],
6063                |row| row.get::<_, String>(0),
6064            )
6065            .ok();
6066        let resolved = persisted
6067            .clone()
6068            .or(client_id.clone())
6069            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
6070        if let (Some(existing), Some(requested)) = (persisted, client_id) {
6071            if existing != requested {
6072                return Err(format!(
6073                    "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
6074                ));
6075            }
6076        }
6077        Self::with_connection(resolved, schema_json, limits, conn)
6078    }
6079
6080    #[must_use]
6081    pub fn client_id(&self) -> &str {
6082        &self.client_id
6083    }
6084
6085    /// Build a client over a caller-supplied rusqlite connection — the seam a
6086    /// native host (Tauri plugin, FFI file-DB variant) uses to back the core
6087    /// with an on-disk database (`Connection::open(path)`) rather than the
6088    /// default `:memory:`. The connection MUST be fresh (no pre-existing
6089    /// syncular tables); `create_tables` runs `IF NOT EXISTS`, so re-opening
6090    /// the same file across process restarts reuses the persisted rows.
6091    pub fn with_connection(
6092        client_id: String,
6093        schema_json: &Value,
6094        limits: ClientLimits,
6095        conn: Connection,
6096    ) -> Result<Self, String> {
6097        if limits.outcome_retention_max_entries == Some(0) {
6098            return Err(
6099                "sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned(),
6100            );
6101        }
6102        let schema = parse_schema_json(schema_json)?;
6103        // RFC 0005 D8: resolve the previous-version config before the opening
6104        // reset runs — the capture happens inside it. Bad bounds fail loud.
6105        let previous_version = match limits.previous_version_context {
6106            Some(config) => {
6107                config.validate()?;
6108                Some(config)
6109            }
6110            None => None,
6111        };
6112        let mut client = SyncClient {
6113            #[cfg(feature = "bench-internals")]
6114            benchmark_phases: Recorder::default(),
6115            conn,
6116            schema,
6117            client_id,
6118            limits,
6119            subs: Vec::new(),
6120            outbox: Vec::new(),
6121            failed_commits: Vec::new(),
6122            retain_failed_commits: false,
6123            conflicts: Vec::new(),
6124            rejections: Vec::new(),
6125            schema_floor: None,
6126            lease_state: None,
6127            stopped: false,
6128            upgrading: false,
6129            sync_needed: false,
6130            realtime_policy: RealtimePolicy::Optional,
6131            realtime_state: RealtimeState::Disconnected,
6132            realtime_reason_code: None,
6133            realtime_retry_delay_ms: None,
6134            presence: HashMap::new(),
6135            now_ms: None,
6136            encryption: crate::values::EncryptionConfig::default(),
6137            security_preflight: false,
6138            transport_enabled: true,
6139            previous_version,
6140            insert_sql: RefCell::new(HashMap::new()),
6141            overlay_dirty: OverlayDirty::clean(),
6142            storage_failure: RefCell::new(None),
6143            #[cfg(test)]
6144            overlay_rebuild_count: Cell::new(0),
6145            #[cfg(test)]
6146            outcome_prune_count: Cell::new(0),
6147            progress: ProgressObserver::default(),
6148            change_queue: VecDeque::new(),
6149            sync_intent_queue: VecDeque::new(),
6150            retry_delay_ms: 250,
6151            round_retry_delay_ms: None,
6152            active_round: None,
6153            last_round: None,
6154            last_change: None,
6155            query_failures: Vec::new(),
6156        };
6157        // The row write path leans on the prepared-statement cache (two
6158        // insert statements per synced table, plus the bookkeeping
6159        // statements); size it so a multi-table schema never thrashes.
6160        client
6161            .conn
6162            .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
6163        // Protected bookkeeping must exist before inspecting the persisted
6164        // schema marker. New app indexes may reference columns that only
6165        // exist after a version-bump reset.
6166        client.create_bookkeeping_tables()?;
6167        match client.get_meta(CLIENT_ID_KEY) {
6168            Some(existing) if existing != client.client_id => {
6169                return Err(format!(
6170                    "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {:?}",
6171                    client.client_id
6172                ));
6173            }
6174            None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
6175            _ => {}
6176        }
6177        client.restore_persisted_state()?;
6178        client.failed_commits = client.load_failed_commits()?;
6179        let marker = client
6180            .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6181            .and_then(|value| value.parse::<i32>().ok());
6182        match marker {
6183            None => {
6184                client.create_synced_tables()?;
6185                client.set_meta(LOCAL_SCHEMA_VERSION_KEY, &client.schema.version.to_string());
6186                // D1: the descriptor is written beside every marker write.
6187                set_local_schema_descriptor(&client.conn, &client.schema);
6188                client.save_subscription_scope_schema();
6189            }
6190            Some(version) if version == client.schema.version => {
6191                client.create_synced_tables()?;
6192                // RFC 0005 D1: the same-version open backfills the descriptor
6193                // for a database first opened by an unaware binary, so the
6194                // NEXT bump can capture without needing a schema bump first.
6195                set_local_schema_descriptor(&client.conn, &client.schema);
6196                client.save_subscription_scope_schema();
6197            }
6198            Some(_) => client.run_schema_reset()?,
6199        }
6200        // RFC 0005 D9: the reset sweep cannot see a container whose capture
6201        // committed in its OWN file while the replica's savepoint never
6202        // released — a crash at that seam leaves a container beside a matching
6203        // OLD marker, and the next open at that version runs no reset. Discard
6204        // it before anything can read it. No-op when no container exists.
6205        client.reconcile_previous_version_at_boot()?;
6206        client.clear_satisfied_persisted_schema_floor();
6207        client.prune_unknown_subscriptions(false)?;
6208        if marker == Some(client.schema.version)
6209            && (!client.outbox.is_empty()
6210                || !client.failed_commits.is_empty()
6211                || client.has_acknowledged_rows())
6212        {
6213            // Reconstruct the visible optimistic overlay from the durable base
6214            // plus outbox instead of trusting a process-interrupted mirror.
6215            client.overlay_dirty.set(true);
6216            client.rebuild_overlay();
6217        }
6218        // Every persisted active subscription needs one catch-up pull on open:
6219        // realtime only covers changes after connection, while an idempotent
6220        // setWindow correctly creates no fresh command effect. Pending outbox
6221        // work has the same restart requirement. The core owns this intent so
6222        // native hosts never poll or require an application-issued sync().
6223        client.enqueue_startup_sync_if_needed()?;
6224        Ok(client)
6225    }
6226
6227    /// Pin the client clock (epoch ms) — the §5.4 expiry check runs
6228    /// against this instead of system time (conformance virtual clock).
6229    pub fn set_now_ms(&mut self, now_ms: i64) {
6230        self.now_ms = Some(now_ms);
6231    }
6232
6233    /// §5.11: install the client-side encryption keys (`keyId → key bytes`).
6234    /// The command router parses these from the `create` command's
6235    /// `encryption` config (keys as `{$bytes: hex}`).
6236    pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
6237        self.encryption = encryption;
6238    }
6239
6240    #[must_use]
6241    pub fn security_lifecycle(&self) -> &'static str {
6242        if self.security_preflight {
6243            "preflight"
6244        } else {
6245            "active"
6246        }
6247    }
6248
6249    #[must_use]
6250    pub fn security_preflight(&self) -> bool {
6251        self.security_preflight
6252    }
6253
6254    /// Host-owned, non-persisted network gate. Local operations remain available.
6255    pub fn set_transport_enabled(&mut self, transport: &mut dyn Transport, enabled: bool) {
6256        if self.transport_enabled != enabled {
6257            self.transport_enabled = enabled;
6258            self.sync_intent_queue.clear();
6259            self.sync_intent_queue
6260                .push_back(if enabled && !self.security_preflight {
6261                    SyncIntent::Interactive
6262                } else {
6263                    SyncIntent::None
6264                });
6265        }
6266        // A captured reply keeps its normal apply/revocation semantics.
6267        if !enabled && self.active_round.is_none() {
6268            self.disconnect_realtime(transport);
6269        }
6270    }
6271
6272    pub fn transport_enabled(&self) -> bool {
6273        self.transport_enabled
6274    }
6275
6276    /// Quarantine this replica: enter the fail-closed gate, release all
6277    /// core-owned key material, and record the gate in the database so
6278    /// reopening re-enters preflight until `activate_security` clears it.
6279    ///
6280    /// Use [`SyncClient::seal_security_on_teardown`] for the shutdown barrier,
6281    /// which must NOT leave that durable mark.
6282    pub fn begin_security_preflight(&mut self) {
6283        self.seal_security_on_teardown();
6284        // Persist the quarantine so it survives handle teardown and restart:
6285        // reopening this replica re-enters preflight until activation clears it.
6286        self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
6287    }
6288
6289    /// Release core-owned key material as a host tears the client down,
6290    /// WITHOUT recording a quarantine.
6291    ///
6292    /// Shutting an activated client down is not a quarantine event. Persisting
6293    /// the gate here marks every cleanly closed replica as pending, and the
6294    /// next plain `create` is then refused permanently: the reopen path
6295    /// restores the flag from the marker and the create guard rejects it. The
6296    /// in-memory flag still closes the gate for anything still holding this
6297    /// instance, which is all a teardown barrier needs.
6298    pub fn seal_security_on_teardown(&mut self) {
6299        self.cancel_sync_round();
6300        self.security_preflight = true;
6301        self.encryption = crate::values::EncryptionConfig::default();
6302        self.sync_intent_queue.clear();
6303    }
6304
6305    /// Install the post-authentication keyring and release the host loop.
6306    pub fn activate_security(
6307        &mut self,
6308        encryption: crate::values::EncryptionConfig,
6309    ) -> Result<(), String> {
6310        if !self.security_preflight {
6311            return Err(
6312                "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
6313            );
6314        }
6315        self.enqueue_startup_sync_if_needed()?;
6316        self.encryption = encryption;
6317        self.security_preflight = false;
6318        self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
6319        Ok(())
6320    }
6321
6322    fn clock_now_ms(&self) -> i64 {
6323        self.now_ms.unwrap_or_else(|| {
6324            std::time::SystemTime::now()
6325                .duration_since(std::time::UNIX_EPOCH)
6326                .map(|d| d.as_millis() as i64)
6327                .unwrap_or(0)
6328        })
6329    }
6330
6331    fn create_bookkeeping_tables(&self) -> Result<(), String> {
6332        // Durable client bookkeeping (outbox + subscription + meta).
6333        self.conn
6334            .execute_batch(
6335                "CREATE TABLE IF NOT EXISTS _syncular_outbox (
6336                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
6337                   commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
6338                 CREATE TABLE IF NOT EXISTS _syncular_row_deliveries (
6339                   tbl TEXT NOT NULL, id TEXT NOT NULL, commit_seq INTEGER NOT NULL, PRIMARY KEY(tbl,id));
6340                 CREATE TABLE IF NOT EXISTS _syncular_acked_rows (
6341                   commit_id TEXT NOT NULL, idx INTEGER NOT NULL, tbl TEXT NOT NULL, id TEXT NOT NULL,
6342                   commit_seq INTEGER NOT NULL, op_json TEXT NOT NULL, intent_json TEXT,
6343                   PRIMARY KEY(commit_id, idx));
6344                 CREATE TABLE IF NOT EXISTS _syncular_failed_commits (
6345                   seq INTEGER PRIMARY KEY AUTOINCREMENT, client_commit_id TEXT NOT NULL UNIQUE,
6346                   operations_json TEXT NOT NULL, initial_json TEXT NOT NULL);
6347                 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
6348                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
6349                   client_commit_id TEXT NOT NULL UNIQUE,
6350                   status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
6351                   recorded_at_ms INTEGER NOT NULL,
6352                   results_json TEXT NOT NULL,
6353                   operations_json TEXT,
6354                   resolution TEXT NOT NULL DEFAULT 'active'
6355                     CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
6356                   resolved_at_ms INTEGER,
6357                   replacement_client_commit_id TEXT);
6358                 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
6359                   ON _syncular_commit_outcomes(resolution, seq);
6360                 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
6361                   id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
6362                 CREATE TABLE IF NOT EXISTS _syncular_meta (
6363                   key TEXT PRIMARY KEY, value TEXT NOT NULL);
6364                 CREATE TABLE IF NOT EXISTS _syncular_windows (
6365                   base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
6366                   PRIMARY KEY (base, unit));
6367                 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
6368                   sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
6369                   effective_scopes TEXT NOT NULL);
6370                 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
6371                   commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
6372                   PRIMARY KEY(commit_id, blob_id));
6373                 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
6374                   ON _syncular_blob_commit_refs(blob_id);",
6375            )
6376            .map_err(|e| e.to_string())?;
6377        // Migrate an outcome journal created before failed aggregate
6378        // envelopes were retained. Historical rows intentionally stay NULL.
6379        let _ = self
6380            .conn
6381            .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
6382        if self.get_meta(LOCAL_REVISION_KEY).is_none() {
6383            self.set_meta(LOCAL_REVISION_KEY, "0");
6384        }
6385        // §5.9.7 blob cache. Mutable upload state stays off the body row so
6386        // completing a large upload does not rewrite its BLOB record.
6387        if self.schema_has_blobs() {
6388            let exists = self
6389                .conn
6390                .query_row(
6391                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
6392                    [],
6393                    |row| row.get::<_, bool>(0),
6394                )
6395                .map_err(|e| e.to_string())?;
6396            if exists {
6397                let mut statement = self
6398                    .conn
6399                    .prepare("PRAGMA table_info(_syncular_blobs)")
6400                    .map_err(|e| e.to_string())?;
6401                let mut columns = statement
6402                    .query_map([], |row| row.get::<_, String>(1))
6403                    .map_err(|e| e.to_string())?
6404                    .collect::<Result<Vec<_>, _>>()
6405                    .map_err(|e| e.to_string())?;
6406                columns.sort();
6407                let mut expected = vec![
6408                    "blob_id",
6409                    "byte_length",
6410                    "bytes",
6411                    "created_at_ms",
6412                    "media_type",
6413                ];
6414                expected.sort_unstable();
6415                if columns != expected {
6416                    return Err(
6417                        "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
6418                    );
6419                }
6420            } else {
6421                self.conn
6422                    .execute_batch(
6423                        "CREATE TABLE _syncular_blobs(
6424                           blob_id TEXT PRIMARY KEY,
6425                           bytes BLOB NOT NULL,
6426                           byte_length INTEGER NOT NULL,
6427                           media_type TEXT,
6428                           created_at_ms INTEGER NOT NULL);",
6429                    )
6430                    .map_err(|e| e.to_string())?;
6431            }
6432            self.conn
6433                .execute_batch(
6434                    "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
6435                       blob_id TEXT PRIMARY KEY,
6436                       media_type TEXT,
6437                       created_at_ms INTEGER NOT NULL);",
6438                )
6439                .map_err(|e| e.to_string())?;
6440        }
6441        Ok(())
6442    }
6443
6444    /// True iff any synced table declares a `blob_ref` column (§5.9).
6445    fn schema_has_blobs(&self) -> bool {
6446        self.schema
6447            .tables
6448            .iter()
6449            .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
6450    }
6451
6452    // -- meta (§7.4.1 marker, bookkeeping) ------------------------------------
6453
6454    fn get_meta(&self, key: &str) -> Option<String> {
6455        meta_get(&self.conn, key)
6456    }
6457
6458    fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
6459        self.conn
6460            .query_row(
6461                "SELECT value FROM _syncular_meta WHERE key = ?1",
6462                rusqlite::params![key],
6463                |row| row.get::<_, String>(0),
6464            )
6465            .optional()
6466            .map_err(|_| {
6467                "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
6468            })
6469    }
6470
6471    fn set_meta(&self, key: &str, value: &str) {
6472        meta_set(&self.conn, key, value);
6473    }
6474
6475    fn delete_meta(&self, key: &str) {
6476        meta_delete(&self.conn, key);
6477    }
6478
6479    fn restore_persisted_state(&mut self) -> Result<(), String> {
6480        self.subs = {
6481            let mut stmt = self
6482                .conn
6483                .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
6484                .map_err(|error| error.to_string())?;
6485            let rows = stmt
6486                .query_map([], |row| {
6487                    Ok((
6488                        row.get::<_, String>(0)?,
6489                        row.get::<_, String>(1)?,
6490                        row.get::<_, String>(2)?,
6491                    ))
6492                })
6493                .map_err(|error| error.to_string())?;
6494            let mut subscriptions = Vec::new();
6495            for row in rows {
6496                let (id, table, raw) = row.map_err(|error| error.to_string())?;
6497                let state: Value = serde_json::from_str(&raw)
6498                    .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
6499                let requested = json_to_scope_map(
6500                    state.get("requested").unwrap_or(&Value::Object(Map::new())),
6501                )?;
6502                let effective = state
6503                    .get("effectiveScopes")
6504                    .filter(|value| !value.is_null())
6505                    .map(json_to_scope_map)
6506                    .transpose()?;
6507                subscriptions.push(Subscription {
6508                    id,
6509                    table,
6510                    requested,
6511                    params: state
6512                        .get("params")
6513                        .and_then(Value::as_str)
6514                        .map(str::to_owned),
6515                    cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
6516                    bootstrap_state: state
6517                        .get("bootstrapState")
6518                        .and_then(Value::as_str)
6519                        .map(str::to_owned),
6520                    state: SubState::parse(
6521                        state
6522                            .get("status")
6523                            .and_then(Value::as_str)
6524                            .unwrap_or("active"),
6525                    ),
6526                    reason_code: state
6527                        .get("reasonCode")
6528                        .and_then(Value::as_str)
6529                        .map(str::to_owned),
6530                    effective,
6531                    synced_once: state
6532                        .get("syncedOnce")
6533                        .and_then(Value::as_bool)
6534                        .unwrap_or(false),
6535                });
6536            }
6537            subscriptions
6538        };
6539
6540        self.outbox = {
6541            let mut stmt = self
6542                .conn
6543                .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
6544                .map_err(|error| error.to_string())?;
6545            let rows = stmt
6546                .query_map([], |row| {
6547                    Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
6548                })
6549                .map_err(|error| error.to_string())?;
6550            let mut commits = Vec::new();
6551            for row in rows {
6552                let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
6553                let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
6554                    format!("invalid persisted outbox {client_commit_id:?}: {error}")
6555                })?;
6556                let mut ops = Vec::with_capacity(entries.len());
6557                for entry in entries {
6558                    let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
6559                    ops.push(OutboxOp {
6560                        upsert: op == "upsert",
6561                        table: entry
6562                            .get("table")
6563                            .and_then(Value::as_str)
6564                            .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
6565                            .to_owned(),
6566                        row_id: entry
6567                            .get("rowId")
6568                            .and_then(Value::as_str)
6569                            .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
6570                            .to_owned(),
6571                        base_version: entry.get("baseVersion").and_then(Value::as_i64),
6572                        // A legacy entry's `changedFields` key is ignored:
6573                        // the values map is the presence set (§6.1).
6574                        values: entry.get("values").and_then(Value::as_object).cloned(),
6575                    });
6576                }
6577                commits.push(OutboxCommit {
6578                    client_commit_id,
6579                    ops,
6580                });
6581            }
6582            commits
6583        };
6584
6585        self.prune_commit_outcomes()?;
6586        let active = self.commit_outcomes(CommitOutcomeQuery {
6587            active_only: true,
6588            ..CommitOutcomeQuery::default()
6589        })?;
6590        self.conflicts = active
6591            .iter()
6592            .flat_map(|outcome| outcome.results.iter())
6593            .filter_map(|result| match result {
6594                CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
6595                _ => None,
6596            })
6597            .collect();
6598        self.rejections = active
6599            .iter()
6600            .flat_map(|outcome| outcome.results.iter())
6601            .filter_map(|result| match result {
6602                CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
6603                _ => None,
6604            })
6605            .collect();
6606
6607        self.lease_state = self
6608            .get_meta(LEASE_STATE_KEY)
6609            .map(|raw| serde_json::from_str(&raw))
6610            .transpose()
6611            .map_err(|error| format!("invalid persisted lease state: {error}"))?;
6612        self.schema_floor = self
6613            .get_meta(SCHEMA_FLOOR_KEY)
6614            .map(|raw| serde_json::from_str(&raw))
6615            .transpose()
6616            .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
6617        self.stopped = self.schema_floor.is_some();
6618        // A replica left in unactivated preflight reopens gated: the quarantine
6619        // decision persists with the data across handle teardown and restart.
6620        if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
6621            self.security_preflight = true;
6622        }
6623        Ok(())
6624    }
6625
6626    fn save_subscription_scope_schema(&self) {
6627        let declarations: BTreeMap<_, BTreeMap<_, _>> = self
6628            .schema
6629            .tables
6630            .iter()
6631            .map(|table| {
6632                (
6633                    table.name.clone(),
6634                    table
6635                        .scope_variables
6636                        .iter()
6637                        .map(|scope| {
6638                            (
6639                                scope.variable.clone(),
6640                                [scope.prefix.clone(), scope.column.clone()],
6641                            )
6642                        })
6643                        .collect(),
6644                )
6645            })
6646            .collect();
6647        self.set_meta(
6648            "subscriptionScopeSchema",
6649            &serde_json::to_string(&declarations).expect("scope schema serialization"),
6650        );
6651    }
6652
6653    /// Remove registrations with incompatible table/key/prefix/column meaning.
6654    fn prune_unknown_subscriptions(&mut self, bump: bool) -> Result<(), String> {
6655        let previous: Option<BTreeMap<String, BTreeMap<String, [String; 2]>>> = if bump {
6656            self.get_meta("subscriptionScopeSchema")
6657                .map(|raw| serde_json::from_str(&raw))
6658                .transpose()
6659                .map_err(|error| format!("invalid persisted scope schema: {error}"))?
6660        } else {
6661            None
6662        };
6663        let compatible = |table_name: &str, requested: &[(String, Vec<String>)]| {
6664            let Some(table) = self.schema.table(table_name) else {
6665                return false;
6666            };
6667            if requested
6668                .iter()
6669                .any(|(variable, _)| table.scope_column(variable).is_none())
6670            {
6671                return false;
6672            }
6673            if !bump {
6674                return true;
6675            }
6676            let Some(old) = previous.as_ref().and_then(|tables| tables.get(table_name)) else {
6677                return false;
6678            };
6679            let variables: BTreeSet<_> = if requested.is_empty() {
6680                old.keys()
6681                    .cloned()
6682                    .chain(
6683                        table
6684                            .scope_variables
6685                            .iter()
6686                            .map(|scope| scope.variable.clone()),
6687                    )
6688                    .collect()
6689            } else {
6690                requested
6691                    .iter()
6692                    .map(|(variable, _)| variable.clone())
6693                    .collect()
6694            };
6695            variables.iter().all(|variable| {
6696                let current = table
6697                    .scope_variables
6698                    .iter()
6699                    .find(|scope| &scope.variable == variable);
6700                match (old.get(variable), current) {
6701                    (Some(old), Some(current)) => {
6702                        old == &[current.prefix.clone(), current.column.clone()]
6703                    }
6704                    _ => false,
6705                }
6706            })
6707        };
6708        let mut stale_ids: BTreeSet<_> = self
6709            .subs
6710            .iter()
6711            .filter(|sub| !compatible(&sub.table, &sub.requested))
6712            .map(|sub| sub.id.clone())
6713            .collect();
6714        for (id, table, effective) in self.load_pending_evictions()? {
6715            if !compatible(&table, &effective) {
6716                stale_ids.insert(id);
6717            }
6718        }
6719        for id in &stale_ids {
6720            for sql in [
6721                "DELETE FROM _syncular_windows WHERE sub_id = ?1",
6722                "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
6723                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
6724            ] {
6725                self.conn
6726                    .execute(sql, rusqlite::params![id])
6727                    .map_err(|error| error.to_string())?;
6728            }
6729        }
6730        self.subs.retain(|sub| !stale_ids.contains(&sub.id));
6731        Ok(())
6732    }
6733
6734    #[must_use]
6735    pub fn local_revision(&self) -> u64 {
6736        self.get_meta(LOCAL_REVISION_KEY)
6737            .and_then(|value| value.parse().ok())
6738            .unwrap_or(0)
6739    }
6740
6741    #[must_use]
6742    pub fn status_snapshot(&self) -> SyncStatusSnapshot {
6743        SyncStatusSnapshot {
6744            current_schema_version: self.schema.version,
6745            outbox: self.outbox.len(),
6746            upgrading: self.upgrading,
6747            lease_state: self.lease_state.clone(),
6748            schema_floor: self.schema_floor.clone(),
6749            sync_needed: self.sync_needed,
6750            previous_version_context: self.previous_version_status(),
6751        }
6752    }
6753
6754    /// RFC 0005 A2: container presence for a host that must act on it (Diego's
6755    /// rollback precondition), read without creating the file.
6756    fn previous_version_status(&self) -> PreviousVersionStatus {
6757        let Some(replica_path) = self.previous_version_replica_path() else {
6758            return PreviousVersionStatus {
6759                present: false,
6760                created_at_ms: None,
6761            };
6762        };
6763        match crate::previous_version::read_previous_version_record(&replica_path) {
6764            Ok(Some(record)) => PreviousVersionStatus {
6765                present: true,
6766                created_at_ms: Some(record.created_at_ms),
6767            },
6768            _ => PreviousVersionStatus {
6769                present: std::path::Path::new(
6770                    &crate::previous_version::previous_version_container_path(&replica_path),
6771                )
6772                .exists(),
6773                created_at_ms: None,
6774            },
6775        }
6776    }
6777
6778    /// RFC 0005 D7: read captured rows from the previous local schema. `state`
6779    /// is always `previousVersion`; the container never makes
6780    /// `query_snapshot()` coverage complete. Lease stop/expiry, scope
6781    /// revocation, coverage completion and the TTL DISCARD the container and
6782    /// close the read with their own reason.
6783    pub fn previous_version_snapshot(
6784        &self,
6785        spec: &PreviousVersionReadSpec,
6786    ) -> Result<PreviousVersionSnapshot, String> {
6787        let now_ms = self.clock_now_ms();
6788        crate::previous_version::previous_version_snapshot(
6789            &self.conn,
6790            self.previous_version_replica_path().as_deref(),
6791            self.previous_version.as_ref(),
6792            PreviousVersionLifecycle {
6793                lease_inactive: self.previous_version_lease_inactive(now_ms),
6794                scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
6795                coverage_complete: self.previous_version_coverage_complete(),
6796            },
6797            now_ms,
6798            self.schema.version,
6799            spec,
6800        )
6801    }
6802
6803    /// RFC 0005 D6: the advisory pre-reset compatibility audit.
6804    #[must_use]
6805    pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
6806        crate::previous_version::stored_previous_version_audit(&self.conn)
6807    }
6808
6809    /// RFC 0005 A2/D9: the executable downgrade step. Drop the container file
6810    /// and both metadata records, and report whether anything was present.
6811    /// Idempotent: absence is a successful no-op.
6812    pub fn previous_version_discard(
6813        &self,
6814    ) -> crate::previous_version::PreviousVersionDiscardOutcome {
6815        self.drop_previous_version()
6816    }
6817
6818    /// The module's discard has no failure path (it removes the file even when
6819    /// the container cannot be opened), so the only error a caller could ever
6820    /// see is a bug; report absence rather than claiming a discard happened.
6821    fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
6822        let absent = crate::previous_version::PreviousVersionDiscardOutcome {
6823            present: false,
6824            discarded: false,
6825        };
6826        let Some(replica_path) = self.previous_version_replica_path() else {
6827            return absent;
6828        };
6829        crate::previous_version::discard_previous_version(&self.conn, &replica_path)
6830            .unwrap_or(absent)
6831    }
6832
6833    /// RFC 0005 D7: `leaseState.errorCode` or an expired lease.
6834    fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
6835        let Some(lease) = &self.lease_state else {
6836            return false;
6837        };
6838        if lease.error_code.is_some() {
6839            return true;
6840        }
6841        lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
6842    }
6843
6844    /// RFC 0005 D7: §7.4.5 replacement coverage — every ACTIVE subscription
6845    /// has `cursor >= 0` and no resume token. An empty active set is not
6846    /// completion (mirrors the TS predicate exactly).
6847    fn previous_version_coverage_complete(&self) -> bool {
6848        let active = self
6849            .subs
6850            .iter()
6851            .filter(|sub| sub.state == SubState::Active)
6852            .collect::<Vec<_>>();
6853        if active.is_empty() {
6854            return false;
6855        }
6856        active
6857            .iter()
6858            .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
6859    }
6860
6861    /// RFC 0005 D7: lifetime triggers evaluated once per sync round, after the
6862    /// apply. A container that no longer has a reason to exist is discarded so
6863    /// the rows do not outlive the window they were kept for.
6864    fn previous_version_lifetime_check(&self) {
6865        let Some(config) = self.previous_version.filter(|config| config.enabled) else {
6866            return;
6867        };
6868        let now_ms = self.clock_now_ms();
6869        if self.previous_version_lease_inactive(now_ms) {
6870            let _ = self.drop_previous_version();
6871            return;
6872        }
6873        let Some(replica_path) = self.previous_version_replica_path() else {
6874            return;
6875        };
6876        let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
6877        else {
6878            return;
6879        };
6880        if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
6881            let _ = self.drop_previous_version();
6882            return;
6883        }
6884        if now_ms - record.created_at_ms > config.max_age_ms {
6885            let _ = self.drop_previous_version();
6886            return;
6887        }
6888        if self.previous_version_coverage_complete() {
6889            let _ = self.drop_previous_version();
6890        }
6891    }
6892
6893    pub fn diagnostics_snapshot(
6894        &self,
6895        request: &ClientDiagnosticsRequest,
6896    ) -> Result<ClientDiagnosticsSnapshot, String> {
6897        if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
6898            return Err(format!(
6899                "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
6900            ));
6901        }
6902        let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
6903        for sub in &self.subs {
6904            let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
6905            let complete =
6906                sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
6907            let state = match sub.state {
6908                SubState::Revoked => "revoked",
6909                SubState::Failed => "failed",
6910                SubState::Active if reset => "reset",
6911                SubState::Active if complete => "complete",
6912                SubState::Active => "bootstrapping",
6913            };
6914            subscriptions.insert(
6915                sub.id.clone(),
6916                DiagnosticSubscription {
6917                    id: sub.id.clone(),
6918                    table: sub.table.clone(),
6919                    state: state.to_owned(),
6920                    complete,
6921                    cursor: Some(sub.cursor),
6922                    reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
6923                },
6924            );
6925        }
6926        for expected in &request.expected_subscriptions {
6927            if expected.id.is_empty() || expected.table.is_empty() {
6928                return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
6929            }
6930            if subscriptions
6931                .get(&expected.id)
6932                .is_some_and(|registered| registered.table != expected.table)
6933            {
6934                subscriptions.insert(
6935                    expected.id.clone(),
6936                    DiagnosticSubscription {
6937                        id: expected.id.clone(),
6938                        table: expected.table.clone(),
6939                        state: "failed".to_owned(),
6940                        complete: false,
6941                        cursor: None,
6942                        reason_code: Some("client.subscription_intent_mismatch".to_owned()),
6943                    },
6944                );
6945            } else {
6946                subscriptions.entry(expected.id.clone()).or_insert_with(|| {
6947                    DiagnosticSubscription {
6948                        id: expected.id.clone(),
6949                        table: expected.table.clone(),
6950                        state: "unregistered".to_owned(),
6951                        complete: false,
6952                        cursor: None,
6953                        reason_code: None,
6954                    }
6955                });
6956            }
6957        }
6958        let mut ordered_subscriptions = Vec::new();
6959        let mut included = BTreeSet::new();
6960        for expected in &request.expected_subscriptions {
6961            if included.insert(expected.id.clone()) {
6962                if let Some(subscription) = subscriptions.get(&expected.id) {
6963                    ordered_subscriptions.push(subscription.clone());
6964                }
6965            }
6966        }
6967        for (id, subscription) in &subscriptions {
6968            if included.insert(id.clone()) {
6969                ordered_subscriptions.push(subscription.clone());
6970            }
6971        }
6972        let subscriptions_truncated =
6973            ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
6974        ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
6975        let captured_at_ms = self.clock_now_ms();
6976        let lease = if let Some(error_code) = self
6977            .lease_state
6978            .as_ref()
6979            .and_then(|state| state.error_code.clone())
6980        {
6981            ClientDiagnosticsLease {
6982                state: "stopped".to_owned(),
6983                expires_at_ms: self
6984                    .lease_state
6985                    .as_ref()
6986                    .and_then(|state| state.expires_at_ms),
6987                error_code: Some(Self::diagnostic_code(&error_code)),
6988            }
6989        } else if let Some(expires_at_ms) = self
6990            .lease_state
6991            .as_ref()
6992            .and_then(|state| state.expires_at_ms)
6993        {
6994            ClientDiagnosticsLease {
6995                state: if expires_at_ms <= captured_at_ms {
6996                    "expired".to_owned()
6997                } else {
6998                    "active".to_owned()
6999                },
7000                expires_at_ms: Some(expires_at_ms),
7001                error_code: None,
7002            }
7003        } else {
7004            ClientDiagnosticsLease {
7005                state: "none".to_owned(),
7006                expires_at_ms: None,
7007                error_code: None,
7008            }
7009        };
7010        let connectivity = match self.last_round.as_ref() {
7011            _ if !self.transport_enabled && self.active_round.is_none() => "offline",
7012            Some(round) if round.status == "succeeded" => "online",
7013            Some(round)
7014                if round.status == "failed"
7015                    && round
7016                        .error_code
7017                        .as_deref()
7018                        .is_some_and(Self::retryable_transport_code) =>
7019            {
7020                "offline"
7021            }
7022            _ => "unknown",
7023        };
7024        Ok(ClientDiagnosticsSnapshot {
7025            version: CLIENT_DIAGNOSTICS_VERSION,
7026            captured_at_ms,
7027            host: ClientDiagnosticsHost {
7028                kind: "direct".to_owned(),
7029                role: "single".to_owned(),
7030                connectivity: connectivity.to_owned(),
7031                realtime: self.realtime_state,
7032                realtime_policy: self.realtime_policy,
7033                realtime_reason_code: self.realtime_reason_code.clone(),
7034                realtime_retry_delay_ms: self.realtime_retry_delay_ms,
7035            },
7036            security_lifecycle: self.security_lifecycle().to_owned(),
7037            schema: ClientDiagnosticsSchema {
7038                current_version: self.schema.version,
7039                upgrading: self.upgrading,
7040                required_version: self
7041                    .schema_floor
7042                    .as_ref()
7043                    .and_then(|floor| floor.required_schema_version),
7044                latest_version: self
7045                    .schema_floor
7046                    .as_ref()
7047                    .and_then(|floor| floor.latest_schema_version),
7048            },
7049            replica: ClientDiagnosticsReplica {
7050                local_revision: self.local_revision().to_string(),
7051                sync_needed: self.sync_needed,
7052                pending_outbox: self.outbox.len(),
7053            },
7054            lease,
7055            subscriptions: ordered_subscriptions,
7056            subscriptions_truncated,
7057            last_round: self.last_round.clone(),
7058            last_change: self.last_change.clone(),
7059            storage: self.diagnostics_storage(),
7060            query_failures: self.query_failures.clone(),
7061        })
7062    }
7063
7064    fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
7065        let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
7066            let page_count: i64 = self
7067                .conn
7068                .query_row("PRAGMA page_count", [], |row| row.get(0))?;
7069            let page_size: i64 = self
7070                .conn
7071                .query_row("PRAGMA page_size", [], |row| row.get(0))?;
7072            // Reuse compiled aggregates; every invocation still reads current storage.
7073            let outbox_bytes: i64 = self
7074                .conn
7075                .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
7076                .query_row([], |row| row.get(0))?;
7077            let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
7078                "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
7079            )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
7080            let blob_bytes = if self.schema_has_blobs() {
7081                self.conn
7082                    .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
7083                    .query_row([], |row| row.get(0))?
7084            } else {
7085                0
7086            };
7087            let pressure = self
7088                .limits
7089                .blob_cache_max_bytes
7090                .is_some_and(|limit| blob_bytes > limit);
7091            Ok(ClientDiagnosticsStorage {
7092                status: if pressure { "pressure" } else { "healthy" }.to_owned(),
7093                database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
7094                pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
7095                retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
7096                retained_outcome_entries: Some(outcome_entries.max(0)),
7097                blob_cache_bytes_approx: Some(blob_bytes.max(0)),
7098                pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
7099            })
7100        };
7101        read().unwrap_or_else(|_| ClientDiagnosticsStorage {
7102            status: "unreadable".to_owned(),
7103            database_bytes_approx: None,
7104            pending_outbox_bytes_approx: None,
7105            retained_outcome_bytes_approx: None,
7106            retained_outcome_entries: None,
7107            blob_cache_bytes_approx: None,
7108            pressure_reason_code: None,
7109        })
7110    }
7111
7112    pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
7113        self.change_queue.drain(..).collect()
7114    }
7115
7116    pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
7117        let intents = self.sync_intent_queue.drain(..).collect();
7118        if self.transport_enabled {
7119            intents
7120        } else {
7121            vec![SyncIntent::None]
7122        }
7123    }
7124
7125    fn schedule_background_retry(&mut self) -> u64 {
7126        let delay_ms = self.retry_delay_ms;
7127        self.sync_intent_queue
7128            .push_back(SyncIntent::Background { delay_ms });
7129        self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
7130        self.round_retry_delay_ms = Some(delay_ms);
7131        delay_ms
7132    }
7133
7134    fn reset_background_retry(&mut self) {
7135        self.retry_delay_ms = 250;
7136    }
7137
7138    fn retryable_transport_code(code: &str) -> bool {
7139        code == "transport.failed"
7140            || code == "transport.unavailable"
7141            || code == "sync.transport_failed"
7142    }
7143
7144    /// A failed round worth a background retry: a transport failure, or a
7145    /// §10.2 code the catalog marks retryable (clients may hardcode the
7146    /// catalog metadata, §10.1). Matches the TypeScript core, which retries
7147    /// on the server's `retryable` flag.
7148    fn retryable_failure_code(code: &str) -> bool {
7149        Self::retryable_transport_code(code)
7150            || matches!(
7151                code,
7152                "sync.auth_required"
7153                    | "sync.auth_lease_required"
7154                    | "sync.auth_lease_revoked"
7155                    | "sync.internal_error"
7156                    | "sync.idempotency_cache_miss"
7157                    | "sync.schema_not_ready"
7158                    | "sync.segment_expired"
7159                    | "sync.rate_limited"
7160                    | "sync.websocket_connection_limit"
7161            )
7162    }
7163
7164    fn diagnostic_code(code: &str) -> String {
7165        let valid = !code.is_empty()
7166            && code.len() <= 96
7167            && code.contains('.')
7168            && code
7169                .bytes()
7170                .next()
7171                .is_some_and(|byte| byte.is_ascii_lowercase())
7172            && code.bytes().all(|byte| {
7173                byte.is_ascii_lowercase()
7174                    || byte.is_ascii_digit()
7175                    || matches!(byte, b'.' | b'_' | b'-')
7176            });
7177        if valid {
7178            code.to_owned()
7179        } else {
7180            "client.unknown_failure".to_owned()
7181        }
7182    }
7183
7184    fn set_sync_needed(&mut self, value: bool, interactive: bool) {
7185        if self.sync_needed != value {
7186            if self.begin_observation("syncular_status").is_ok() {
7187                self.sync_needed = value;
7188                let batch = ChangeAccumulator {
7189                    status: true,
7190                    ..ChangeAccumulator::default()
7191                };
7192                if self.finish_observation("syncular_status", batch).is_err() {
7193                    self.rollback_observation("syncular_status");
7194                }
7195            } else {
7196                self.sync_needed = value;
7197            }
7198        }
7199        if value && interactive {
7200            self.sync_intent_queue.push_back(SyncIntent::Interactive);
7201        }
7202    }
7203
7204    fn sqlite_failure(
7205        storage_failure: &RefCell<Option<QueryReadFailure>>,
7206        error: rusqlite::Error,
7207    ) -> String {
7208        let message = error.to_string();
7209        let failure = QueryReadFailure::from(error);
7210        let code = failure.code;
7211        if code.is_some() && storage_failure.borrow().is_none() {
7212            *storage_failure.borrow_mut() = Some(failure);
7213        }
7214        match code {
7215            Some(code) => format!("{code}: {message}"),
7216            None => message,
7217        }
7218    }
7219
7220    fn begin_observation(&self, name: &str) -> Result<(), String> {
7221        self.conn
7222            .execute_batch(&format!("SAVEPOINT {name}"))
7223            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7224    }
7225
7226    fn rollback_observation(&self, name: &str) {
7227        if let Err(error) = self
7228            .conn
7229            .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"))
7230        {
7231            if let Some(failure) = self.storage_failure.borrow_mut().as_mut() {
7232                if failure.rollback_failure.is_none() {
7233                    failure.rollback_failure = Some(Box::new(QueryReadFailure::from(error)));
7234                }
7235            }
7236        }
7237    }
7238
7239    fn finish_observation(
7240        &mut self,
7241        name: &str,
7242        mut batch: ChangeAccumulator,
7243    ) -> Result<(), String> {
7244        #[cfg(feature = "bench-internals")]
7245        let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
7246        if self.failed_commits.iter().any(|(commit, _)| {
7247            commit
7248                .ops
7249                .iter()
7250                .any(|operation| batch.tables.contains_key(&operation.table))
7251        }) {
7252            batch.outcomes = true;
7253        }
7254        if !batch.touched() {
7255            self.conn
7256                .execute_batch(&format!("RELEASE {name}"))
7257                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7258            return Ok(());
7259        }
7260        let revision = self
7261            .local_revision()
7262            .checked_add(1)
7263            .ok_or_else(|| "local revision exhausted u64".to_owned())?;
7264        self.conn
7265            .execute(
7266                "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
7267                rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
7268            )
7269            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7270        let status = batch.status.then(|| self.status_snapshot());
7271        let event = ClientChangeBatch {
7272            revision: revision.to_string(),
7273            tables: batch
7274                .tables
7275                .into_iter()
7276                .map(|(table, scope_keys)| TableChange {
7277                    table,
7278                    scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
7279                })
7280                .collect(),
7281            windows: batch
7282                .windows
7283                .into_iter()
7284                .map(|((base_key, table), units)| WindowChange {
7285                    base_key,
7286                    table,
7287                    units: units.into_iter().collect(),
7288                })
7289                .collect(),
7290            status,
7291            conflicts_changed: batch.conflicts,
7292            rejections_changed: batch.rejections,
7293            outcomes_changed: batch.outcomes,
7294        };
7295        self.conn
7296            .execute_batch(&format!("RELEASE {name}"))
7297            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7298        let mut diagnostic_tables = event
7299            .tables
7300            .iter()
7301            .map(|entry| entry.table.clone())
7302            .collect::<BTreeSet<_>>()
7303            .into_iter()
7304            .collect::<Vec<_>>();
7305        let mut diagnostic_windows = event
7306            .windows
7307            .iter()
7308            .map(|entry| entry.table.clone())
7309            .collect::<BTreeSet<_>>()
7310            .into_iter()
7311            .collect::<Vec<_>>();
7312        let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
7313            || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
7314        diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
7315        diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
7316        self.last_change = Some(DiagnosticLastChange {
7317            revision: event.revision.clone(),
7318            recorded_at_ms: self.clock_now_ms(),
7319            tables: diagnostic_tables,
7320            windows: diagnostic_windows,
7321            domains_truncated,
7322            status_changed: event.status.is_some(),
7323            conflicts_changed: event.conflicts_changed,
7324            rejections_changed: event.rejections_changed,
7325            outcomes_changed: event.outcomes_changed,
7326        });
7327        self.change_queue.push_back(event);
7328        Ok(())
7329    }
7330
7331    fn record_scope_map(
7332        &self,
7333        batch: &mut ChangeAccumulator,
7334        table_name: &str,
7335        scopes: &[(String, Vec<String>)],
7336    ) {
7337        let Some(table) = self.schema.table(table_name) else {
7338            return;
7339        };
7340        for (variable, values) in scopes {
7341            let Some(scope) = table
7342                .scope_variables
7343                .iter()
7344                .find(|scope| &scope.variable == variable)
7345            else {
7346                continue;
7347            };
7348            for value in values {
7349                batch.scope(table_name, format!("{}:{value}", scope.prefix));
7350            }
7351        }
7352    }
7353
7354    /// Record a row's current scope keys from the base or visible table.
7355    fn record_row_scopes(
7356        &self,
7357        batch: &mut ChangeAccumulator,
7358        table_name: &str,
7359        row_id: &str,
7360        base: bool,
7361    ) -> bool {
7362        let Some(table) = self.schema.table(table_name) else {
7363            return false;
7364        };
7365        if table.scope_variables.is_empty() {
7366            return false;
7367        }
7368        let columns = table
7369            .scope_variables
7370            .iter()
7371            .map(|scope| quote_ident(&scope.column))
7372            .collect::<Vec<_>>()
7373            .join(", ");
7374        let full_table = if base {
7375            base_table(table_name)
7376        } else {
7377            visible_table(table_name)
7378        };
7379        let sql = format!(
7380            "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
7381            row_id_predicate(table)
7382        );
7383        let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
7384            return false;
7385        };
7386        let values = stmt.query_row(rusqlite::params![row_id], |row| {
7387            let mut values = Vec::with_capacity(table.scope_variables.len());
7388            for index in 0..table.scope_variables.len() {
7389                values.push(row.get::<_, Option<String>>(index)?);
7390            }
7391            Ok(values)
7392        });
7393        let Ok(values) = values else {
7394            return false;
7395        };
7396        let mut recorded = false;
7397        for (scope, value) in table.scope_variables.iter().zip(values) {
7398            if let Some(value) = value {
7399                batch.scope(table_name, format!("{}:{value}", scope.prefix));
7400                recorded = true;
7401            }
7402        }
7403        recorded
7404    }
7405
7406    fn record_commit_changes(
7407        &self,
7408        batch: &mut ChangeAccumulator,
7409        tables: &[String],
7410        changes: &[ssp2::model::Change],
7411    ) {
7412        #[cfg(feature = "bench-internals")]
7413        let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
7414        for change in changes {
7415            let Some(table_name) = tables.get(change.table_index as usize) else {
7416                continue;
7417            };
7418            let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
7419            if let Some(table) = self.schema.table(table_name) {
7420                for (variable, value) in &change.scopes {
7421                    if let Some(scope) = table
7422                        .scope_variables
7423                        .iter()
7424                        .find(|scope| &scope.variable == variable)
7425                    {
7426                        batch.scope(table_name, format!("{}:{value}", scope.prefix));
7427                        precise = true;
7428                    }
7429                }
7430            }
7431            if !precise {
7432                batch.table(table_name);
7433            }
7434        }
7435    }
7436
7437    fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
7438        if effective.is_empty() {
7439            return false;
7440        }
7441        let Some(table) = self.schema.table(table_name) else {
7442            return false;
7443        };
7444        let mut clauses = Vec::new();
7445        let mut params = Vec::new();
7446        for (variable, values) in effective {
7447            let Some(column) = table.scope_column(variable) else {
7448                return false;
7449            };
7450            if values.is_empty() {
7451                return false;
7452            }
7453            let placeholders = values
7454                .iter()
7455                .map(|value| {
7456                    params.push(SqlValue::Text(value.clone()));
7457                    "?"
7458                })
7459                .collect::<Vec<_>>()
7460                .join(", ");
7461            clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
7462        }
7463        let sql = format!(
7464            "SELECT 1 FROM {} WHERE {} LIMIT 1",
7465            base_table(table_name),
7466            clauses.join(" AND ")
7467        );
7468        self.conn
7469            .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
7470            .is_ok()
7471    }
7472
7473    /// §7.4.5: true while a schema-bump reset + first re-bootstrap runs.
7474    pub fn upgrading(&self) -> bool {
7475        self.upgrading
7476    }
7477
7478    /// §7.4.2 "app ships new code": swap to a NEW generated schema while
7479    /// keeping this client's local database (identity, outbox, tables). The
7480    /// §7.4.1 marker check then fires the wipe/re-bootstrap flow when the
7481    /// version changed. Mirrors the TS client's boot-time detection —
7482    /// the Rust core has no persistent restart, so recreation IS the boot.
7483    pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
7484        let new_schema = parse_schema_json(schema_json)?;
7485        let marker: Option<i32> = self
7486            .get_meta(LOCAL_SCHEMA_VERSION_KEY)
7487            .and_then(|v| v.parse().ok());
7488        self.schema = new_schema;
7489        self.create_bookkeeping_tables()?;
7490        if marker != Some(self.schema.version) {
7491            self.run_schema_reset()?;
7492        }
7493        self.prune_unknown_subscriptions(false)?;
7494        self.save_subscription_scope_schema();
7495        // The conformance recreate is the in-memory equivalent of reopening a
7496        // durable client. Apply the same startup catch-up contract even when
7497        // the schema itself did not change.
7498        self.enqueue_startup_sync_if_needed()?;
7499        Ok(())
7500    }
7501
7502    fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
7503        let startup_work = !self.stopped
7504            && (!self.outbox.is_empty()
7505                || self.has_acknowledged_rows()
7506                || !self.load_pending_evictions()?.is_empty()
7507                || self.subs.iter().any(|sub| sub.state == SubState::Active));
7508        if startup_work {
7509            self.sync_needed = true;
7510            self.sync_intent_queue.push_back(SyncIntent::Interactive);
7511        }
7512        Ok(())
7513    }
7514
7515    /// A native client persists schema-floor stops across process restarts.
7516    /// Once the running app already satisfies that floor, the persisted stop
7517    /// is only stale evidence from an older server round (for example, the
7518    /// server was restarted after catching up to an app that was ahead).
7519    /// Clear it and let the normal startup pull re-negotiate. If the server is
7520    /// still incompatible it will return the floor again in that first round.
7521    fn clear_satisfied_persisted_schema_floor(&mut self) {
7522        let satisfied = self
7523            .schema_floor
7524            .as_ref()
7525            .and_then(|floor| floor.required_schema_version)
7526            .is_some_and(|required| self.schema.version >= required);
7527        if !satisfied {
7528            return;
7529        }
7530        self.schema_floor = None;
7531        self.stopped = false;
7532        self.delete_meta(SCHEMA_FLOOR_KEY);
7533    }
7534
7535    /// RFC 0005 D1: discard an orphan or stale container at boot. The reset
7536    /// path already swept it; this covers a same-version or fresh-install open,
7537    /// which runs no reset and therefore no sweep — plus the D7 TTL, which only
7538    /// an aware binary can apply.
7539    fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
7540        let Some(replica_path) = self.previous_version_replica_path() else {
7541            return Ok(());
7542        };
7543        let max_age_ms = self
7544            .previous_version
7545            .filter(|config| config.enabled)
7546            .map(|config| config.max_age_ms);
7547        reconcile_previous_version_at_boot(
7548            &self.conn,
7549            &replica_path,
7550            self.schema.version,
7551            max_age_ms,
7552            self.clock_now_ms(),
7553        )
7554    }
7555
7556    /// RFC 0005 D3: the sibling container path, or `None` when the replica has
7557    /// no file — an in-memory replica returns an empty path, and a `file:` URI
7558    /// has no plain sibling path — so the feature resolves as off there.
7559    fn previous_version_replica_path(&self) -> Option<String> {
7560        let path = self.conn.path()?;
7561        if path.is_empty() || path.starts_with("file:") {
7562            return None;
7563        }
7564        Some(path.to_owned())
7565    }
7566
7567    /// §7.4.3 reset: whole-database local reset EXCEPT the outbox, clientId,
7568    /// and leaseState. Drops/recreates every synced table from the new
7569    /// schema, resets subscription sync-state (keeping registrations), clears
7570    /// the schema-floor stop state, rewrites the marker, drops outbox commits
7571    /// that cannot re-encode (§7.4.4), and replays the survivors on top.
7572    fn run_schema_reset(&mut self) -> Result<(), String> {
7573        self.begin_observation("syncular_schema_reset")?;
7574        let prior_subs = self.subs.clone();
7575        let mut batch = ChangeAccumulator::default();
7576        let result = self.run_schema_reset_observed(&mut batch, true, true);
7577        if let Err(error) = result {
7578            self.rollback_observation("syncular_schema_reset");
7579            self.subs = prior_subs;
7580            return Err(error);
7581        }
7582        if let Err(error) = self.finish_observation("syncular_schema_reset", batch) {
7583            self.rollback_observation("syncular_schema_reset");
7584            self.subs = prior_subs;
7585            return Err(error);
7586        }
7587        Ok(())
7588    }
7589
7590    fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
7591        let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
7592        self.begin_observation("syncular_log_epoch_reset")?;
7593        let prior_subs = self.subs.clone();
7594        let mut batch = ChangeAccumulator::default();
7595        let result = self.run_schema_reset_observed(&mut batch, false, false);
7596        if let Err(error) = result {
7597            self.rollback_observation("syncular_log_epoch_reset");
7598            self.subs = prior_subs;
7599            return Err(error);
7600        }
7601        self.set_meta(LOG_EPOCH_KEY, log_epoch);
7602        self.sync_needed = true;
7603        batch.status = true;
7604        if let Err(error) = self.finish_observation("syncular_log_epoch_reset", batch) {
7605            self.rollback_observation("syncular_log_epoch_reset");
7606            self.subs = prior_subs;
7607            return Err(error);
7608        }
7609        self.sync_intent_queue.push_back(SyncIntent::Interactive);
7610        Ok(resets)
7611    }
7612
7613    fn run_schema_reset_observed(
7614        &mut self,
7615        batch: &mut ChangeAccumulator,
7616        drop_incompatible: bool,
7617        capture: bool,
7618    ) -> Result<(), String> {
7619        self.conn
7620            .execute_batch(
7621                "DELETE FROM _syncular_acked_rows; DELETE FROM _syncular_row_deliveries;",
7622            )
7623            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7624        self.cancel_sync_round();
7625        self.upgrading = true;
7626        batch.status = true;
7627        // RFC 0005 D6: classify the pending outbox against the NEW compiled
7628        // schema BEFORE the wipe, and record the advisory audit. It drops
7629        // nothing — the §7.4.4 send-time drop stays the only path that removes
7630        // a commit. The classification is shared with that drop so the two can
7631        // never disagree about what is incompatible.
7632        if self.previous_version.is_some_and(|config| config.enabled) {
7633            let previous_version = self
7634                .get_meta(LOCAL_SCHEMA_VERSION_KEY)
7635                .and_then(|value| value.parse::<i32>().ok())
7636                .unwrap_or(self.schema.version);
7637            let pending = self
7638                .outbox
7639                .iter()
7640                .map(|commit| PendingCommitAudit {
7641                    commit_id: &commit.client_commit_id,
7642                    operations: commit_audit_operations(commit),
7643                })
7644                .collect::<Vec<_>>();
7645            crate::previous_version::write_previous_version_audit(
7646                &self.conn,
7647                &crate::previous_version::build_previous_version_audit(
7648                    &self.schema,
7649                    &pending,
7650                    previous_version,
7651                    self.schema.version,
7652                    self.clock_now_ms(),
7653                ),
7654            );
7655        }
7656        // RFC 0005 D5: the container lives in its own FILE, so the reset can
7657        // never reach it. Sweep first (unconditional — also on the log-epoch
7658        // reset, which captures nothing), then capture BEFORE the wipe drops
7659        // any row. A refused capture records its reason and leaves no file.
7660        if let Some(replica_path) = self.previous_version_replica_path() {
7661            sweep_previous_version_container(&self.conn, &replica_path)?;
7662            if capture {
7663                if let Some(config) = self.previous_version.filter(|config| config.enabled) {
7664                    let previous_version = self
7665                        .get_meta(LOCAL_SCHEMA_VERSION_KEY)
7666                        .and_then(|value| value.parse::<i32>().ok())
7667                        .unwrap_or(self.schema.version);
7668                    capture_previous_version_from_replica(
7669                        &self.conn,
7670                        &replica_path,
7671                        previous_version,
7672                        self.schema.version,
7673                        &config,
7674                        self.clock_now_ms(),
7675                    )?;
7676                }
7677            }
7678        }
7679        for table in &self.schema.tables {
7680            batch.table(&table.name);
7681        }
7682        for (base_key, unit, table) in self.load_registered_window_units() {
7683            batch.window(&base_key, &table, &unit);
7684        }
7685        if capture {
7686            self.prune_unknown_subscriptions(true)?;
7687        }
7688        // The per-table insert SQL is derived from the OLD column lists.
7689        self.insert_sql.borrow_mut().clear();
7690        self.overlay_dirty.set(true);
7691        // Drop every synced table (base + visible) that currently exists —
7692        // discovered from sqlite_master so a bump that adds/removes tables is
7693        // handled. Bookkeeping tables (`_syncular_outbox/_subscriptions/_meta`
7694        // and the blob cache) are preserved; base tables are `_syncular_base_*`
7695        // so they are matched explicitly, not by the bookkeeping filter.
7696        // Drop virtual tables first so SQLite removes every FTS shadow table
7697        // atomically instead of the generic discovery tearing it apart.
7698        let virtual_tables: Vec<String> = {
7699            let mut stmt = self
7700                .conn
7701                .prepare(
7702                    "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
7703                )
7704                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7705            let rows = stmt
7706                .query_map([], |row| row.get::<_, String>(0))
7707                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7708            rows.filter_map(Result::ok)
7709                .filter(|name| is_synced_table_name(name))
7710                .collect()
7711        };
7712        for name in virtual_tables {
7713            self.conn
7714                .execute_batch(&format!(
7715                    "DROP TABLE IF EXISTS {}",
7716                    quote_ident(&format!("_syncular_fts_{name}"))
7717                ))
7718                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7719            self.conn
7720                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
7721                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7722        }
7723        let existing: Vec<String> = {
7724            let mut stmt = self
7725                .conn
7726                .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
7727                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7728            let rows = stmt
7729                .query_map([], |row| row.get::<_, String>(0))
7730                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7731            rows.filter_map(Result::ok)
7732                .filter(|name| is_synced_table_name(name))
7733                .collect()
7734        };
7735        for name in &existing {
7736            if let Some(table) = name.strip_prefix("_syncular_base_") {
7737                batch.table(table);
7738            } else if !name.starts_with("_syncular_") {
7739                batch.table(name);
7740            }
7741        }
7742        for name in existing {
7743            self.conn
7744                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
7745                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7746        }
7747        // Recreate the synced tables from the NEW schema.
7748        self.create_synced_tables()?;
7749        // Reset every subscription's sync-state, keeping the registration.
7750        for sub in &mut self.subs {
7751            sub.cursor = -1;
7752            sub.bootstrap_state = None;
7753            sub.effective = None;
7754            sub.state = SubState::Active;
7755            sub.reason_code = None;
7756            sub.synced_once = false;
7757        }
7758        let subs = self.subs.clone();
7759        for sub in &subs {
7760            self.persist_sub(sub)?;
7761        }
7762        // The stop state is over: this client now ships a servable schema.
7763        self.stopped = false;
7764        self.schema_floor = None;
7765        self.delete_meta(SCHEMA_FLOOR_KEY);
7766        // Rewrite the marker LAST so a crash mid-reset re-runs the reset.
7767        self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
7768        // D1: the descriptor follows every marker write. A crash in between
7769        // self-heals on the next same-version open, which backfills it.
7770        set_local_schema_descriptor(&self.conn, &self.schema);
7771        self.save_subscription_scope_schema();
7772        // §7.4.4: drop outbox commits that cannot re-encode under the new
7773        // schema (a referenced column/table the bump removed), surfacing each
7774        // as a `sync.outbox_incompatible` rejection.
7775        if drop_incompatible && self.drop_incompatible_outbox()? {
7776            batch.rejections = true;
7777            batch.status = true;
7778            batch.outcomes = true;
7779        }
7780        // Re-apply the surviving outbox optimistically over the empty tables.
7781        self.rebuild_overlay();
7782        Ok(())
7783    }
7784
7785    /// §7.4.4: a persisted upsert whose values reference a column the current
7786    /// schema lacks (or a removed table) cannot be encoded. Drop the commit
7787    /// and raise a client-local `sync.outbox_incompatible` rejection.
7788    fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
7789        let incompatible = self
7790            .outbox
7791            .iter()
7792            .filter(|commit| {
7793                first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
7794            })
7795            .cloned()
7796            .collect::<Vec<_>>();
7797        if incompatible.is_empty() {
7798            return Ok(false);
7799        }
7800        let mut rejections = Vec::new();
7801        for commit in &incompatible {
7802            let results = commit
7803                .ops
7804                .iter()
7805                .enumerate()
7806                .map(|(op_index, operation)| {
7807                    let rejection = RejectionRecord {
7808                        client_commit_id: commit.client_commit_id.clone(),
7809                        op_index: op_index as i32,
7810                        code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
7811                        message: "the persisted commit cannot encode under the current schema"
7812                            .to_owned(),
7813                        retryable: false,
7814                        details: None,
7815                        operation: Some(CommitOperation::from(operation)),
7816                    };
7817                    rejections.push(rejection.clone());
7818                    CommitOperationOutcome::Error { rejection }
7819                })
7820                .collect::<Vec<_>>();
7821            self.persist_commit_outcome(
7822                &commit.client_commit_id,
7823                CommitOutcomeStatus::Rejected,
7824                &results,
7825                Some(&commit.ops),
7826            )?;
7827            self.delete_outbox_persisted(&commit.client_commit_id)?;
7828        }
7829        self.prune_commit_outcomes()?;
7830        let incompatible_ids = incompatible
7831            .iter()
7832            .map(|commit| commit.client_commit_id.as_str())
7833            .collect::<BTreeSet<_>>();
7834        self.outbox
7835            .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
7836        self.rejections.extend(rejections);
7837        Ok(true)
7838    }
7839
7840    /// §7.4.3: (re)create the base + visible table pair for every synced
7841    /// table in the CURRENT schema (idempotent — `IF NOT EXISTS`).
7842    fn create_synced_tables(&self) -> Result<(), String> {
7843        for table in &self.schema.tables {
7844            // The base half + the visible half form the synced-table pair. An
7845            // index name is global in SQLite, so the base half's indexes are
7846            // name-prefixed (`_syncular_base_<index>`) to stay distinct.
7847            for (full, index_prefix) in [
7848                (base_table(&table.name), "_syncular_base_"),
7849                (visible_table(&table.name), ""),
7850            ] {
7851                let mut cols: Vec<String> =
7852                    table.columns.iter().map(|c| quote_ident(&c.name)).collect();
7853                cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
7854                let sql = format!(
7855                    "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
7856                    cols.join(", "),
7857                    quote_ident(&table.primary_key)
7858                );
7859                self.conn
7860                    .execute(&sql, [])
7861                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7862                // Local secondary indexes (CREATE INDEX subset). Created on
7863                // both halves so mirror reads hit an index on either. Runs on
7864                // both the initial create and the §7.4.3 reset recreate path.
7865                for index in &table.indexes {
7866                    let unique = if index.unique { "UNIQUE " } else { "" };
7867                    let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
7868                    let cols_sql = index
7869                        .columns
7870                        .iter()
7871                        .map(|c| quote_ident(c))
7872                        .collect::<Vec<_>>()
7873                        .join(", ");
7874                    let index_sql = format!(
7875                        "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
7876                    );
7877                    self.conn
7878                        .execute(&index_sql, [])
7879                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7880                }
7881            }
7882        }
7883        // FTS exists only on the visible half. It is a local search
7884        // projection, never a synced/base table or a wire-schema member.
7885        for table in &self.schema.tables {
7886            for index in &table.fts_indexes {
7887                self.create_fts_projection(table, index)?;
7888            }
7889        }
7890        Ok(())
7891    }
7892
7893    fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
7894        let count: i64 = self
7895            .conn
7896            .query_row(
7897                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
7898                rusqlite::params![index.name],
7899                |row| row.get(0),
7900            )
7901            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7902        Ok(count > 0)
7903    }
7904
7905    fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
7906        for suffix in ["ai", "ad", "au"] {
7907            self.conn
7908                .execute(
7909                    &format!(
7910                        "DROP TRIGGER IF EXISTS {}",
7911                        quote_ident(&format!("{}_{suffix}", index.name))
7912                    ),
7913                    [],
7914                )
7915                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7916        }
7917        Ok(())
7918    }
7919
7920    fn create_fts_triggers(
7921        &self,
7922        table: &TableSchema,
7923        index: &FtsIndexSchema,
7924    ) -> Result<(), String> {
7925        let fts = quote_ident(&index.name);
7926        let source = visible_table(&table.name);
7927        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
7928        let pk = quote_ident(&table.primary_key);
7929        let projection_columns = std::iter::once(source_id.clone())
7930            .chain(index.columns.iter().map(|column| quote_ident(column)))
7931            .collect::<Vec<_>>()
7932            .join(", ");
7933        let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
7934            .chain(
7935                index
7936                    .columns
7937                    .iter()
7938                    .map(|column| format!("new.{}", quote_ident(column))),
7939            )
7940            .collect::<Vec<_>>()
7941            .join(", ");
7942        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7943        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)");
7944        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)");
7945        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})");
7946        let bi = quote_ident(&format!("{}_bi", index.name));
7947        let ai = quote_ident(&format!("{}_ai", index.name));
7948        let ad = quote_ident(&format!("{}_ad", index.name));
7949        let au = quote_ident(&format!("{}_au", index.name));
7950        let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
7951        let sql = format!(
7952            "DROP TRIGGER IF EXISTS {bi};
7953             DROP TRIGGER IF EXISTS {ai};
7954             DROP TRIGGER IF EXISTS {ad};
7955             DROP TRIGGER IF EXISTS {au};
7956             CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
7957             CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
7958             CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
7959             CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
7960        );
7961        self.conn
7962            .execute_batch(&sql)
7963            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7964    }
7965
7966    fn rebuild_fts_projection(
7967        &self,
7968        table: &TableSchema,
7969        index: &FtsIndexSchema,
7970    ) -> Result<(), String> {
7971        let fts = quote_ident(&index.name);
7972        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
7973        let pk = quote_ident(&table.primary_key);
7974        let indexed_columns = index
7975            .columns
7976            .iter()
7977            .map(|column| quote_ident(column))
7978            .collect::<Vec<_>>();
7979        let projection_columns = std::iter::once(source_id.clone())
7980            .chain(indexed_columns.iter().cloned())
7981            .collect::<Vec<_>>()
7982            .join(", ");
7983        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7984        let sql = format!(
7985            "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};",
7986            columns = indexed_columns.join(", "),
7987            source = visible_table(&table.name),
7988        );
7989        self.conn
7990            .execute_batch(&sql)
7991            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7992    }
7993
7994    fn create_fts_projection(
7995        &self,
7996        table: &TableSchema,
7997        index: &FtsIndexSchema,
7998    ) -> Result<(), String> {
7999        self.begin_observation("syncular_fts_schema")?;
8000        let result = (|| {
8001            let existed = self.fts_projection_exists(index)?;
8002            let tokenizer = index.tokenize.replace('\'', "''");
8003            let columns = index
8004                .columns
8005                .iter()
8006                .map(|column| quote_ident(column))
8007                .collect::<Vec<_>>()
8008                .join(", ");
8009            let sql = format!(
8010            "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
8011            fts = quote_ident(&index.name),
8012            source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
8013        );
8014            self.conn.execute(&sql, []).map_err(|error| {
8015                format!(
8016                    "cannot create local FTS5 projection {:?}: {error}",
8017                    index.name
8018                )
8019            })?;
8020            let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
8021            let mapped: bool = self
8022                .conn
8023                .query_row(
8024                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
8025                    [format!("_syncular_fts_{}", index.name)],
8026                    |row| row.get(0),
8027                )
8028                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8029            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))?;
8030            if !mapped && existed {
8031                self.conn
8032                    .execute_batch(&format!(
8033                        "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
8034                        quote_ident(FTS_SOURCE_ID_COLUMN),
8035                        quote_ident(&index.name)
8036                    ))
8037                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8038            }
8039            self.create_fts_triggers(table, index)?;
8040            if !existed {
8041                self.rebuild_fts_projection(table, index)?;
8042            }
8043            Ok(())
8044        })();
8045        if let Err(error) = result {
8046            self.rollback_observation("syncular_fts_schema");
8047            return Err(error);
8048        }
8049        if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
8050            self.rollback_observation("syncular_fts_schema");
8051            return Err(error.to_string());
8052        }
8053        Ok(())
8054    }
8055
8056    // -- persistence write-through --------------------------------------------
8057
8058    fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
8059        let state = serde_json::json!({
8060            "requested": scope_map_to_json(&sub.requested),
8061            "params": sub.params,
8062            "cursor": sub.cursor,
8063            "bootstrapState": sub.bootstrap_state,
8064            "status": sub.state.name(),
8065            "reasonCode": sub.reason_code,
8066            "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
8067            "syncedOnce": sub.synced_once,
8068        });
8069        self.conn.execute(
8070            "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
8071            rusqlite::params![sub.id, sub.table, state.to_string()],
8072        ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8073    }
8074
8075    fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
8076        let ops: Vec<Value> = commit
8077            .ops
8078            .iter()
8079            .map(|op| {
8080                serde_json::json!({
8081                    "op": if op.upsert { "upsert" } else { "delete" },
8082                    "table": op.table,
8083                    "rowId": op.row_id,
8084                    "baseVersion": op.base_version,
8085                    "values": op.values.clone().map(Value::Object),
8086                })
8087            })
8088            .collect();
8089        self.conn
8090            .execute(
8091                "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
8092                rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
8093            )
8094            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8095        for operation in &commit.ops {
8096            let Some(values) = &operation.values else {
8097                continue;
8098            };
8099            let Some(table) = self.schema.table(&operation.table) else {
8100                continue;
8101            };
8102            for column in table
8103                .columns
8104                .iter()
8105                .filter(|column| column.ty == ColumnType::BlobRef)
8106            {
8107                let Some(Value::String(raw)) = values.get(&column.name) else {
8108                    continue;
8109                };
8110                let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
8111                    continue;
8112                };
8113                let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
8114                    continue;
8115                };
8116                self.conn
8117                    .execute(
8118                        "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
8119                         SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
8120                        rusqlite::params![commit.client_commit_id, blob_id],
8121                    )
8122                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8123            }
8124        }
8125        Ok(())
8126    }
8127
8128    fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
8129        if let Some(commit) = self
8130            .outbox
8131            .iter()
8132            .find(|commit| commit.client_commit_id == client_commit_id)
8133        {
8134            for operation in &commit.ops {
8135                self.overlay_dirty.table(&operation.table);
8136            }
8137        }
8138
8139        self.conn
8140            .execute(
8141                "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
8142                rusqlite::params![client_commit_id],
8143            )
8144            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8145        self.conn
8146            .execute(
8147                "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
8148                rusqlite::params![client_commit_id],
8149            )
8150            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8151        self.conn.execute("DELETE FROM _syncular_row_deliveries WHERE NOT EXISTS(SELECT 1 FROM _syncular_outbox,json_each(ops_json) WHERE json_extract(json_each.value,'$.table')=tbl AND json_extract(json_each.value,'$.rowId')=id)", [])
8152            .map(|_| ())
8153            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8154    }
8155
8156    fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
8157        match status {
8158            CommitOutcomeStatus::Applied => "applied",
8159            CommitOutcomeStatus::Cached => "cached",
8160            CommitOutcomeStatus::Conflict => "conflict",
8161            CommitOutcomeStatus::Rejected => "rejected",
8162        }
8163    }
8164
8165    fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
8166        match resolution {
8167            CommitOutcomeResolution::Active => "active",
8168            CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
8169            CommitOutcomeResolution::Superseded => "superseded",
8170            CommitOutcomeResolution::Dismissed => "dismissed",
8171        }
8172    }
8173
8174    fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
8175        match value {
8176            "applied" => Ok(CommitOutcomeStatus::Applied),
8177            "cached" => Ok(CommitOutcomeStatus::Cached),
8178            "conflict" => Ok(CommitOutcomeStatus::Conflict),
8179            "rejected" => Ok(CommitOutcomeStatus::Rejected),
8180            _ => Err(format!("invalid persisted commit outcome status {value:?}")),
8181        }
8182    }
8183
8184    fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
8185        match value {
8186            "active" => Ok(CommitOutcomeResolution::Active),
8187            "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
8188            "superseded" => Ok(CommitOutcomeResolution::Superseded),
8189            "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
8190            _ => Err(format!(
8191                "invalid persisted commit outcome resolution {value:?}"
8192            )),
8193        }
8194    }
8195
8196    fn persist_commit_outcome(
8197        &self,
8198        client_commit_id: &str,
8199        status: CommitOutcomeStatus,
8200        results: &[CommitOperationOutcome],
8201        operations: Option<&[OutboxOp]>,
8202    ) -> Result<(), String> {
8203        let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
8204        let operations_json = operations
8205            .map(|items| {
8206                serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
8207            })
8208            .transpose()
8209            .map_err(|error| error.to_string())?;
8210        self.conn
8211            .execute(
8212                "INSERT INTO _syncular_commit_outcomes (
8213                   client_commit_id, status, recorded_at_ms, results_json,
8214                   operations_json, resolution
8215                 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
8216                rusqlite::params![
8217                    client_commit_id,
8218                    Self::outcome_status_name(status),
8219                    self.clock_now_ms(),
8220                    results_json,
8221                    operations_json
8222                ],
8223            )
8224            .map(|_| ())
8225            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8226    }
8227
8228    fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
8229        let StoredCommitOutcomeRow {
8230            sequence,
8231            client_commit_id,
8232            status,
8233            recorded_at_ms,
8234            results_json,
8235            operations_json,
8236            resolution,
8237            resolved_at_ms,
8238            replacement_client_commit_id,
8239        } = row;
8240        Ok(CommitOutcome {
8241            sequence,
8242            client_commit_id,
8243            status: Self::parse_outcome_status(&status)?,
8244            recorded_at_ms,
8245            results: serde_json::from_str(&results_json)
8246                .map_err(|error| format!("invalid persisted commit outcome results: {error}"))?,
8247            operations: operations_json
8248                .map(|value| {
8249                    serde_json::from_str(&value).map_err(|error| {
8250                        format!("invalid persisted commit outcome operations: {error}")
8251                    })
8252                })
8253                .transpose()?,
8254            retained_rows: None,
8255            resolution: Self::parse_outcome_resolution(&resolution)?,
8256            resolved_at_ms,
8257            replacement_client_commit_id,
8258        })
8259    }
8260
8261    /// Resolve declared unique keys through SQLite, including affinity and NULL semantics.
8262    fn unique_conflicts(
8263        &self,
8264        table: &TableSchema,
8265        values: &Map<String, Value>,
8266        base: bool,
8267    ) -> Result<Vec<RetainedUniqueConflict>, String> {
8268        let source = if base {
8269            base_table(&table.name)
8270        } else {
8271            visible_table(&table.name)
8272        };
8273        let mut conflicts = Vec::new();
8274        for index in table.indexes.iter().filter(|index| index.unique) {
8275            let mut params = vec![json_param_to_sql(
8276                values.get(&table.primary_key).ok_or("sync.local_corrupt")?,
8277            )?];
8278            for name in &index.columns {
8279                let column = table
8280                    .columns
8281                    .iter()
8282                    .find(|column| column.name == *name)
8283                    .ok_or("sync.local_corrupt")?;
8284                let cell = json_to_column_value(column, values.get(name))?;
8285                params.push(owned_sql_value(RowParam::Cell(&cell))?);
8286            }
8287            let sql = format!(
8288                "SELECT * FROM {source} WHERE {} != ? AND {}",
8289                quote_ident(&table.primary_key),
8290                index
8291                    .columns
8292                    .iter()
8293                    .map(|column| format!("{} = ?", quote_ident(column)))
8294                    .collect::<Vec<_>>()
8295                    .join(" AND ")
8296            );
8297            let mut statement = self
8298                .conn
8299                .prepare_cached(&sql)
8300                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8301            let records = statement
8302                .query_map(rusqlite::params_from_iter(params), |record| {
8303                    let values = table
8304                        .columns
8305                        .iter()
8306                        .enumerate()
8307                        .map(|(index, column)| {
8308                            let value = sql_ref_to_json(column, record.get_ref(index)?);
8309                            json_to_column_value(column, Some(&value)).map_err(|message| {
8310                                rusqlite::Error::ToSqlConversionFailure(message.into())
8311                            })
8312                        })
8313                        .collect::<Result<Row, _>>()?;
8314                    Ok((values, record.get::<_, i64>(table.columns.len())?))
8315                })
8316                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8317            for record in records {
8318                let (row, version) =
8319                    record.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8320                let row_id = render_row_id_json(Some(&column_value_to_json(&row[table.pk_index])))?;
8321                conflicts.push(RetainedUniqueConflict {
8322                    index: index.name.clone(),
8323                    columns: index.columns.clone(),
8324                    row_id,
8325                    server_row: table
8326                        .columns
8327                        .iter()
8328                        .zip(&row)
8329                        .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
8330                        .collect(),
8331                    server_version: version,
8332                });
8333            }
8334        }
8335        Ok(conflicts)
8336    }
8337
8338    fn attach_retained_rows(&self, mut outcome: CommitOutcome) -> Result<CommitOutcome, String> {
8339        if let Some((commit, initial)) = self
8340            .failed_commits
8341            .iter()
8342            .find(|(commit, _)| commit.client_commit_id == outcome.client_commit_id)
8343        {
8344            let mut rows = Vec::new();
8345            for operation in &commit.ops {
8346                let table = self
8347                    .schema
8348                    .table(&operation.table)
8349                    .ok_or_else(|| "sync.unknown_table".to_owned())?;
8350                let key =
8351                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
8352                        .map_err(|_| "sync.local_corrupt".to_owned())?;
8353                let base = self.stored_row(table, &operation.row_id, true);
8354                rows.push(RetainedCommitRow {
8355                    table: operation.table.clone(),
8356                    row_id: operation.row_id.clone(),
8357                    unique_conflicts: if operation.upsert {
8358                        self.unique_conflicts(
8359                            table,
8360                            initial
8361                                .get(&key)
8362                                .and_then(Value::as_object)
8363                                .or(operation.values.as_ref())
8364                                .ok_or("sync.local_corrupt")?,
8365                            true,
8366                        )?
8367                    } else {
8368                        Vec::new()
8369                    },
8370                    local_row: if operation.upsert {
8371                        initial
8372                            .get(&key)
8373                            .and_then(Value::as_object)
8374                            .cloned()
8375                            .or_else(|| operation.values.clone())
8376                    } else {
8377                        None
8378                    },
8379                    server_row: base.as_ref().map(|(values, _)| {
8380                        table
8381                            .columns
8382                            .iter()
8383                            .zip(values)
8384                            .map(|(column, value)| {
8385                                (column.name.clone(), column_value_to_json(value))
8386                            })
8387                            .collect()
8388                    }),
8389                    server_version: base.map(|(_, version)| version),
8390                });
8391            }
8392            outcome.retained_rows = Some(rows);
8393        }
8394        Ok(outcome)
8395    }
8396
8397    pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
8398        let row = self
8399            .conn
8400            .query_row(
8401                "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
8402                        resolution, resolved_at_ms, replacement_client_commit_id
8403                   FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
8404                rusqlite::params![client_commit_id],
8405                |row| {
8406                    Ok(StoredCommitOutcomeRow {
8407                        sequence: row.get(0)?,
8408                        client_commit_id: row.get(1)?,
8409                        status: row.get(2)?,
8410                        recorded_at_ms: row.get(3)?,
8411                        results_json: row.get(4)?,
8412                        operations_json: row.get(5)?,
8413                        resolution: row.get(6)?,
8414                        resolved_at_ms: row.get(7)?,
8415                        replacement_client_commit_id: row.get(8)?,
8416                    })
8417                },
8418            )
8419            .optional()
8420            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8421        row.map(|row| self.attach_retained_rows(Self::outcome_from_row(row)?))
8422            .transpose()
8423    }
8424
8425    pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
8426        if query.limit == Some(0) {
8427            return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
8428        }
8429        let mut sql = String::from(
8430            "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
8431                    resolution, resolved_at_ms, replacement_client_commit_id
8432               FROM _syncular_commit_outcomes",
8433        );
8434        if query.active_only {
8435            sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
8436        }
8437        sql.push_str(" ORDER BY seq DESC");
8438        if let Some(limit) = query.limit {
8439            sql.push_str(&format!(" LIMIT {limit}"));
8440        }
8441        let mut stmt = self
8442            .conn
8443            .prepare(&sql)
8444            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8445        let rows = stmt
8446            .query_map([], |row| {
8447                Ok(StoredCommitOutcomeRow {
8448                    sequence: row.get(0)?,
8449                    client_commit_id: row.get(1)?,
8450                    status: row.get(2)?,
8451                    recorded_at_ms: row.get(3)?,
8452                    results_json: row.get(4)?,
8453                    operations_json: row.get(5)?,
8454                    resolution: row.get(6)?,
8455                    resolved_at_ms: row.get(7)?,
8456                    replacement_client_commit_id: row.get(8)?,
8457                })
8458            })
8459            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8460        let mut outcomes = Vec::new();
8461        for row in rows {
8462            outcomes.push(self.attach_retained_rows(Self::outcome_from_row(
8463                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
8464            )?)?);
8465        }
8466        Ok(outcomes)
8467    }
8468
8469    fn prune_commit_outcomes(&self) -> Result<(), String> {
8470        #[cfg(test)]
8471        self.outcome_prune_count
8472            .set(self.outcome_prune_count.get() + 1);
8473        let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
8474        let count = self
8475            .conn
8476            .query_row(
8477                "SELECT COUNT(*) FROM _syncular_commit_outcomes",
8478                [],
8479                |row| row.get::<_, i64>(0),
8480            )
8481            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
8482            as usize;
8483        let excess = count.saturating_sub(max_entries);
8484        if excess == 0 {
8485            return Ok(());
8486        }
8487        let mut stmt = self
8488            .conn
8489            .prepare(
8490                "SELECT seq FROM _syncular_commit_outcomes
8491                  WHERE status IN ('applied', 'cached') OR resolution != 'active'
8492                  ORDER BY seq ASC LIMIT ?1",
8493            )
8494            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8495        let rows = stmt
8496            .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
8497            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8498        let sequences = rows
8499            .collect::<Result<Vec<_>, _>>()
8500            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8501        drop(stmt);
8502        for sequence in sequences {
8503            self.conn
8504                .execute(
8505                    "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
8506                    rusqlite::params![sequence],
8507                )
8508                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8509        }
8510        Ok(())
8511    }
8512
8513    // -- driver surface ---------------------------------------------------------
8514
8515    pub fn subscribe(
8516        &mut self,
8517        id: String,
8518        table: String,
8519        scopes: Vec<(String, Vec<String>)>,
8520        params: Option<String>,
8521    ) -> Result<(), String> {
8522        if self.schema.table(&table).is_none() {
8523            return Err(format!("unknown table {table:?}"));
8524        }
8525        if let Some(existing) = self.subs.iter().find(|subscription| subscription.id == id) {
8526            let same_intent = existing.table == table
8527                && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
8528                && existing.params == params;
8529            if same_intent {
8530                return Ok(());
8531            }
8532            return Err(
8533                "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
8534                    .to_owned(),
8535            );
8536        }
8537        let sub = Subscription {
8538            id: id.clone(),
8539            table,
8540            requested: scopes,
8541            params,
8542            cursor: -1,
8543            bootstrap_state: None,
8544            state: SubState::Active,
8545            reason_code: None,
8546            effective: None,
8547            synced_once: false,
8548        };
8549        self.persist_sub(&sub)?;
8550        self.subs.push(sub);
8551        Ok(())
8552    }
8553
8554    pub fn unsubscribe(&mut self, id: &str) {
8555        if self.subs.iter().any(|s| s.id == id) {
8556            self.cancel_sync_round();
8557        }
8558        self.subs.retain(|s| s.id != id);
8559        let _ = self.conn.execute(
8560            "DELETE FROM _syncular_subscriptions WHERE id = ?1",
8561            rusqlite::params![id],
8562        );
8563    }
8564
8565    // -- windowed subscriptions (§4.8) ------------------------------------------
8566
8567    /// §4.8: set the live window units for a base — a value-sharded family
8568    /// of subscriptions, one per unit. Added units get fresh subscriptions
8569    /// (image-lane bootstrap on the next sync); removed units are
8570    /// unsubscribed with their first eviction chunk in one transaction (E1–E4).
8571    /// Idempotent; re-entry cancels any deferred eviction.
8572    pub fn set_window(
8573        &mut self,
8574        base: &WindowBase,
8575        units: &[String],
8576    ) -> Result<CommandEffects, String> {
8577        let table = self
8578            .schema
8579            .table(&base.table)
8580            .ok_or_else(|| format!("unknown table {:?}", base.table))?;
8581        if table.scope_column(&base.variable).is_none() {
8582            return Err(format!(
8583                "setWindow: table {:?} has no scope variable {:?} (§4.8)",
8584                base.table, base.variable
8585            ));
8586        }
8587        let base_key = window_base_key(base);
8588        let wanted: std::collections::HashSet<&String> = units.iter().collect();
8589        let live = self.load_window_units(&base_key);
8590        self.begin_observation("syncular_window")?;
8591        let mut batch = ChangeAccumulator::default();
8592        let mut changed = false;
8593
8594        let prior_subs = self.subs.clone();
8595        let widened = (|| {
8596            // Widen: units wanted but not live → fresh subscription + registry row.
8597            for unit in units {
8598                if live.iter().any(|(u, _)| u == unit) {
8599                    continue;
8600                }
8601                let sub_id = derive_sub_id(base, unit);
8602                self.delete_pending_evict(&sub_id)?;
8603                self.insert_window_unit(&base_key, unit, &sub_id)?;
8604                self.subscribe(
8605                    sub_id,
8606                    base.table.clone(),
8607                    unit_scopes(base, unit),
8608                    base.params.clone(),
8609                )?;
8610                batch.window(&base_key, &base.table, unit);
8611                changed = true;
8612            }
8613
8614            self.finish_observation("syncular_window", batch)
8615        })();
8616        if let Err(error) = widened {
8617            self.rollback_observation("syncular_window");
8618            self.subs = prior_subs;
8619            return Err(error);
8620        }
8621        for (unit, sub_id) in live {
8622            if wanted.contains(&unit) {
8623                continue;
8624            }
8625            self.rebuild_overlay_if_dirty();
8626            let prior_subs = self.subs.clone();
8627            self.begin_observation("syncular_window")?;
8628            let effective = self
8629                .subs
8630                .iter()
8631                .find(|sub| sub.id == sub_id)
8632                .and_then(|sub| sub.effective.clone())
8633                .unwrap_or_else(|| unit_scopes(base, &unit));
8634            let mut batch = ChangeAccumulator::default();
8635            self.record_scope_map(&mut batch, &base.table, &effective);
8636            batch.window(&base_key, &base.table, &unit);
8637            let result = self.evict_unit(&base_key, base, &unit, &sub_id);
8638            let remaining = match result {
8639                Ok(remaining) => remaining,
8640                Err(error) => {
8641                    self.rollback_observation("syncular_window");
8642                    self.subs = prior_subs;
8643                    return Err(error);
8644                }
8645            };
8646            if let Err(error) = self.finish_observation("syncular_window", batch) {
8647                self.rollback_observation("syncular_window");
8648                self.subs = prior_subs;
8649                return Err(error);
8650            }
8651            if remaining {
8652                self.drain_pending_evictions()?;
8653            }
8654            changed = true;
8655        }
8656        Ok(if changed {
8657            CommandEffects::interactive()
8658        } else {
8659            CommandEffects::none()
8660        })
8661    }
8662
8663    /// §4.8 completeness oracle (I3): the windowed-in units for a base plus
8664    /// the subset still bootstrap-pending. Registration alone is not
8665    /// completeness — a unit is pending until its subscription completes a
8666    /// bootstrap round (cursor advances past -1 with no resume token held).
8667    pub fn window_state(&self, base: &WindowBase) -> WindowState {
8668        let mut units = Vec::new();
8669        let mut pending = Vec::new();
8670        for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
8671            let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
8672                Some(sub) => {
8673                    sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
8674                }
8675                None => true,
8676            };
8677            if is_pending {
8678                pending.push(unit.clone());
8679            }
8680            units.push(unit);
8681        }
8682        WindowState { units, pending }
8683    }
8684
8685    fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
8686        let mut stmt = match self
8687            .conn
8688            .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
8689        {
8690            Ok(stmt) => stmt,
8691            Err(_) => return Vec::new(),
8692        };
8693        let rows = stmt.query_map(rusqlite::params![base_key], |row| {
8694            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
8695        });
8696        match rows {
8697            Ok(rows) => rows.filter_map(Result::ok).collect(),
8698            Err(_) => Vec::new(),
8699        }
8700    }
8701
8702    fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
8703        let mut stmt = match self.conn.prepare(
8704            "SELECT windows.base, windows.unit, subscriptions.tbl
8705               FROM _syncular_windows AS windows
8706               JOIN _syncular_subscriptions AS subscriptions
8707                 ON subscriptions.id = windows.sub_id
8708               ORDER BY windows.base, windows.unit",
8709        ) {
8710            Ok(stmt) => stmt,
8711            Err(_) => return Vec::new(),
8712        };
8713        let rows = stmt.query_map([], |row| {
8714            Ok((
8715                row.get::<_, String>(0)?,
8716                row.get::<_, String>(1)?,
8717                row.get::<_, String>(2)?,
8718            ))
8719        });
8720        match rows {
8721            Ok(rows) => rows.filter_map(Result::ok).collect(),
8722            Err(_) => Vec::new(),
8723        }
8724    }
8725
8726    fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
8727        self.conn
8728            .query_row(
8729                "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
8730                rusqlite::params![sub_id],
8731                |row| Ok((row.get(0)?, row.get(1)?)),
8732            )
8733            .ok()
8734    }
8735
8736    fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
8737        self.conn
8738            .execute(
8739                "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
8740                rusqlite::params![base_key, unit, sub_id],
8741            )
8742            .map(|_| ())
8743            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8744    }
8745
8746    fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
8747        self.conn
8748            .execute(
8749                "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
8750                rusqlite::params![base_key, unit],
8751            )
8752            .map(|_| ())
8753            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8754    }
8755
8756    /// §4.8 E1–E4: evict one departing unit, fused with unsubscription.
8757    /// Deletes the unit's rows except those pinned by a pending outbox
8758    /// commit (E1); records a deferred eviction if any pin remains; discards
8759    /// the subscription's cursor/resume/effective-echo (E3) and version
8760    /// state with the rows (E2). Fail-closed: no local mapping ⇒ evict
8761    /// nothing.
8762    fn evict_unit(
8763        &mut self,
8764        base_key: &str,
8765        base: &WindowBase,
8766        unit: &str,
8767        sub_id: &str,
8768    ) -> Result<bool, String> {
8769        let effective = self
8770            .subs
8771            .iter()
8772            .find(|s| s.id == sub_id)
8773            .and_then(|s| s.effective.clone())
8774            .unwrap_or_else(|| unit_scopes(base, unit));
8775        let pinned = self.pinned_row_ids(&base.table);
8776        let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
8777        self.delete_window_unit(base_key, unit)?;
8778        self.conn
8779            .execute(
8780                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
8781                [sub_id],
8782            )
8783            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8784        self.subs.retain(|sub| sub.id != sub_id);
8785        if deferred {
8786            self.save_pending_evict(sub_id, &base.table, &effective)?;
8787        } else {
8788            self.delete_pending_evict(sub_id)?;
8789        }
8790        Ok(remaining)
8791    }
8792
8793    /// §4.8 E1: delete base rows matching effective scopes EXCEPT pinned
8794    /// primary keys. Returns (more unpinned work, any scoped rows remain).
8795    /// A missing scope-column mapping fails without deleting rows.
8796    fn evict_scope_rows(
8797        &self,
8798        table_name: &str,
8799        effective: &[(String, Vec<String>)],
8800        pinned: &std::collections::HashSet<String>,
8801    ) -> Result<(bool, bool), String> {
8802        if effective.is_empty() {
8803            return Ok((false, false));
8804        }
8805        let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
8806        let mut clauses = Vec::new();
8807        let mut params: Vec<SqlValue> = Vec::new();
8808        for (variable, values) in effective {
8809            let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
8810            if values.is_empty() {
8811                return Ok((false, false));
8812            }
8813            let holes = values
8814                .iter()
8815                .map(|v| {
8816                    params.push(SqlValue::Text(v.clone()));
8817                    "?"
8818                })
8819                .collect::<Vec<_>>()
8820                .join(", ");
8821            clauses.push(format!("{} IN ({holes})", quote_ident(column)));
8822        }
8823        let scope = clauses.join(" AND ");
8824        let scope_params = params.len();
8825        let pk = quote_ident(&table.primary_key);
8826        if !pinned.is_empty() {
8827            let holes = pinned
8828                .iter()
8829                .map(|id| {
8830                    params.push(SqlValue::Text(id.clone()));
8831                    "?"
8832                })
8833                .collect::<Vec<_>>()
8834                .join(", ");
8835            clauses.push(format!("{pk} NOT IN ({holes})"));
8836        }
8837        let predicate = clauses.join(" AND ");
8838        let base = base_table(table_name);
8839        let visible = visible_table(table_name);
8840        let mut ids: Vec<SqlValue> = Vec::new();
8841        for source in [&base, &visible] {
8842            let mut stmt = self
8843                .conn
8844                .prepare_cached(&format!(
8845                    "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
8846                ))
8847                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8848            ids = stmt
8849                .query_map(rusqlite::params_from_iter(&params), |row| row.get(0))
8850                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
8851                .collect::<Result<_, _>>()
8852                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8853            if !ids.is_empty() {
8854                break;
8855            }
8856        }
8857        if !ids.is_empty() {
8858            for id in &ids {
8859                self.conn
8860                    .execute(
8861                        "DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2",
8862                        rusqlite::params![table_name, id],
8863                    )
8864                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8865            }
8866            let holes = vec!["?"; ids.len()].join(", ");
8867            for source in [&base, &visible] {
8868                self.conn
8869                    .execute(
8870                        &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
8871                        rusqlite::params_from_iter(&ids),
8872                    )
8873                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8874            }
8875        }
8876        let mut remaining = false;
8877        let mut deferred = false;
8878        for source in [&base, &visible] {
8879            remaining |= self
8880                .conn
8881                .query_row(
8882                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
8883                    rusqlite::params_from_iter(&params),
8884                    |row| row.get::<_, bool>(0),
8885                )
8886                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8887            deferred |= self
8888                .conn
8889                .query_row(
8890                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
8891                    rusqlite::params_from_iter(&params[..scope_params]),
8892                    |row| row.get::<_, bool>(0),
8893                )
8894                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8895        }
8896        Ok((remaining, deferred))
8897    }
8898
8899    /// Resume committed eviction chunks, retaining outbox pins and their durable marker.
8900    fn drain_pending_evictions(&mut self) -> Result<(), String> {
8901        let pending = self.load_pending_evictions()?;
8902        if pending.is_empty() {
8903            return Ok(());
8904        }
8905        self.rebuild_overlay_if_dirty();
8906        for (sub_id, table_name, effective) in pending {
8907            if self.schema.table(&table_name).is_none() {
8908                self.delete_pending_evict(&sub_id)?;
8909                continue;
8910            }
8911            loop {
8912                std::thread::yield_now();
8913                self.begin_observation("syncular_evict")?;
8914                let pinned = self.pinned_row_ids(&table_name);
8915                let result = self.evict_scope_rows(&table_name, &effective, &pinned);
8916                let (remaining, deferred) = match result {
8917                    Ok(result) => result,
8918                    Err(error) => {
8919                        self.rollback_observation("syncular_evict");
8920                        return Err(error);
8921                    }
8922                };
8923                if !deferred {
8924                    if let Err(error) = self.delete_pending_evict(&sub_id) {
8925                        self.rollback_observation("syncular_evict");
8926                        return Err(error);
8927                    }
8928                }
8929                let mut batch = ChangeAccumulator::default();
8930                self.record_scope_map(&mut batch, &table_name, &effective);
8931                if let Err(error) = self.finish_observation("syncular_evict", batch) {
8932                    self.rollback_observation("syncular_evict");
8933                    return Err(error);
8934                }
8935                if !remaining {
8936                    break;
8937                }
8938            }
8939        }
8940        Ok(())
8941    }
8942
8943    /// §4.8 E1: primary keys of `table` referenced by a pending outbox
8944    /// commit — rows that MUST NOT be evicted until the commit drains.
8945    fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
8946        let mut pinned = std::collections::HashSet::new();
8947        for commit in self
8948            .outbox
8949            .iter()
8950            .chain(self.failed_commits.iter().map(|(commit, _)| commit))
8951        {
8952            for op in &commit.ops {
8953                if op.table == table {
8954                    pinned.insert(op.row_id.clone());
8955                }
8956            }
8957        }
8958        pinned
8959    }
8960
8961    fn save_pending_evict(
8962        &self,
8963        sub_id: &str,
8964        table: &str,
8965        effective: &[(String, Vec<String>)],
8966    ) -> Result<(), String> {
8967        self.conn.execute(
8968            "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
8969            rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
8970        ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8971    }
8972
8973    fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
8974        self.conn
8975            .execute(
8976                "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
8977                rusqlite::params![sub_id],
8978            )
8979            .map(|_| ())
8980            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8981    }
8982
8983    fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
8984        let mut stmt = self
8985            .conn
8986            .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
8987            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8988        let rows = stmt
8989            .query_map([], |row| {
8990                Ok((
8991                    row.get::<_, String>(0)?,
8992                    row.get::<_, String>(1)?,
8993                    row.get::<_, String>(2)?,
8994                ))
8995            })
8996            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8997        rows.map(|row| {
8998            let (sub_id, table, json) =
8999                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9000            let value: Value = serde_json::from_str(&json)
9001                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
9002            let effective = json_to_scope_map(&value)
9003                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
9004            Ok((sub_id, table, effective))
9005        })
9006        .collect()
9007    }
9008
9009    /// Select failed-intent retention; reload durable retained aggregates.
9010    pub fn set_retain_failed_commits(&mut self, enabled: bool) -> Result<(), String> {
9011        self.retain_failed_commits = enabled;
9012        for (commit, _) in &self.failed_commits {
9013            for operation in &commit.ops {
9014                self.overlay_dirty.table(&operation.table);
9015            }
9016        }
9017        self.failed_commits = self.load_failed_commits()?;
9018        for (commit, _) in &self.failed_commits {
9019            for operation in &commit.ops {
9020                self.overlay_dirty.table(&operation.table);
9021            }
9022        }
9023        Ok(())
9024    }
9025
9026    fn load_failed_commits(&self) -> Result<Vec<FailedCommit>, String> {
9027        let mut statement = self.conn.prepare("SELECT client_commit_id, operations_json, initial_json FROM _syncular_failed_commits ORDER BY seq")
9028            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9029        let rows = statement
9030            .query_map([], |row| {
9031                Ok((
9032                    row.get::<_, String>(0)?,
9033                    row.get::<_, String>(1)?,
9034                    row.get::<_, String>(2)?,
9035                ))
9036            })
9037            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9038        rows.map(|row| {
9039            let (id, operations, initial) =
9040                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9041            let operations: Vec<CommitOperation> =
9042                serde_json::from_str(&operations).map_err(|_| "sync.local_corrupt".to_owned())?;
9043            let initial =
9044                serde_json::from_str(&initial).map_err(|_| "sync.local_corrupt".to_owned())?;
9045            Ok((
9046                OutboxCommit {
9047                    client_commit_id: id,
9048                    ops: operations
9049                        .into_iter()
9050                        .map(|op| OutboxOp {
9051                            upsert: op.op == "upsert",
9052                            table: op.table,
9053                            row_id: op.row_id,
9054                            base_version: op.base_version,
9055                            values: op.values,
9056                        })
9057                        .collect(),
9058                },
9059                initial,
9060            ))
9061        })
9062        .collect()
9063    }
9064
9065    fn retain_failed_commit(&mut self, id: &str, operations: &[OutboxOp]) -> Result<(), String> {
9066        let mut initial = Map::new();
9067        for operation in operations {
9068            let table = self
9069                .schema
9070                .table(&operation.table)
9071                .ok_or_else(|| "sync.unknown_table".to_owned())?;
9072            if let Some((values, _)) = self
9073                .stored_row(table, &operation.row_id, true)
9074                .or_else(|| self.visible_row(table, &operation.row_id))
9075            {
9076                let mut intended: Map<String, Value> = table
9077                    .columns
9078                    .iter()
9079                    .zip(values.iter())
9080                    .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
9081                    .collect();
9082                if let Some(patch) = &operation.values {
9083                    intended.extend(patch.clone());
9084                }
9085                initial.insert(
9086                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
9087                        .map_err(|_| "sync.local_corrupt".to_owned())?,
9088                    Value::Object(intended),
9089                );
9090            }
9091        }
9092        self.conn.execute("INSERT INTO _syncular_failed_commits(client_commit_id, operations_json, initial_json) VALUES (?1, ?2, ?3)", rusqlite::params![id,
9093            serde_json::to_string(&operations.iter().map(CommitOperation::from).collect::<Vec<_>>()).map_err(|_| "sync.local_corrupt".to_owned())?,
9094            serde_json::to_string(&initial).map_err(|_| "sync.local_corrupt".to_owned())?])
9095            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9096        self.failed_commits.push((
9097            OutboxCommit {
9098                client_commit_id: id.to_owned(),
9099                ops: operations.to_vec(),
9100            },
9101            initial,
9102        ));
9103        Ok(())
9104    }
9105
9106    fn drop_failed_commits(
9107        &mut self,
9108        matches: impl Fn(&str, &Map<String, Value>) -> bool,
9109    ) -> Result<Vec<OutboxCommit>, String> {
9110        let mut doomed = self.drop_acknowledged_rows(&matches)?;
9111        for (commit, initial) in &self.failed_commits {
9112            for operation in &commit.ops {
9113                let key =
9114                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
9115                        .map_err(|_| "sync.local_corrupt".to_owned())?;
9116                if initial
9117                    .get(&key)
9118                    .and_then(Value::as_object)
9119                    .is_some_and(|values| matches(&operation.table, values))
9120                {
9121                    doomed.push(commit.clone());
9122                    break;
9123                }
9124            }
9125        }
9126        for commit in &doomed {
9127            self.conn
9128                .execute(
9129                    "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
9130                    rusqlite::params![commit.client_commit_id],
9131                )
9132                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9133        }
9134        for commit in &doomed {
9135            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))?;
9136        }
9137        self.conflicts.retain(|record| {
9138            !doomed
9139                .iter()
9140                .any(|commit| commit.client_commit_id == record.client_commit_id)
9141        });
9142        self.rejections.retain(|record| {
9143            !doomed
9144                .iter()
9145                .any(|commit| commit.client_commit_id == record.client_commit_id)
9146        });
9147        self.failed_commits.retain(|(commit, _)| {
9148            !doomed
9149                .iter()
9150                .any(|dropped| dropped.client_commit_id == commit.client_commit_id)
9151        });
9152        for commit in &doomed {
9153            for operation in &commit.ops {
9154                self.overlay_dirty.table(&operation.table);
9155            }
9156        }
9157        Ok(doomed)
9158    }
9159
9160    pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, String> {
9161        if mutations.is_empty() {
9162            return Err("a commit must contain at least one operation (§6.1)".to_owned());
9163        }
9164        let mut ops = Vec::with_capacity(mutations.len());
9165        for mutation in mutations {
9166            match mutation {
9167                Mutation::Patch {
9168                    table,
9169                    values,
9170                    base_version,
9171                } => {
9172                    let schema_table = self
9173                        .schema
9174                        .table(&table)
9175                        .ok_or_else(|| "sync.unknown_table".to_owned())?;
9176                    let values = normalize_values_casing(schema_table, values)?;
9177                    let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
9178                    ops.push(self.prepare_patch(&table, &row_id, values, base_version)?);
9179                }
9180                Mutation::Upsert {
9181                    table,
9182                    values,
9183                    base_version,
9184                } => {
9185                    let schema_table = self
9186                        .schema
9187                        .table(&table)
9188                        .ok_or_else(|| format!("unknown table {table:?}"))?;
9189                    // §5: value keys are accepted in snake_case AND the
9190                    // generated row types' camelCase; normalize to SQL truth
9191                    // before the pk lookup / codec see them.
9192                    let values = normalize_values_casing(schema_table, values)?;
9193                    let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
9194                    // §6.7 `mutate` marks every column present: fill missing
9195                    // nullable columns with NULL before the payload is built.
9196                    let values = full_row_values(schema_table, values)?;
9197                    // §5.11: validate the payload encodes with the current
9198                    // codec. A key-selection or unknown-key failure is a
9199                    // durable rejection at the push seam (§10.3), never an
9200                    // author-time error. Full-row `mutate` presents every
9201                    // column, so no stored fallback is needed.
9202                    self.validate_author_encode(schema_table, &row_id, &values)?;
9203                    ops.push(OutboxOp {
9204                        upsert: true,
9205                        table,
9206                        row_id,
9207                        base_version,
9208                        values: Some(values),
9209                    });
9210                }
9211                Mutation::Delete {
9212                    table,
9213                    row_id,
9214                    base_version,
9215                } => {
9216                    if self.schema.table(&table).is_none() {
9217                        return Err(format!("unknown table {table:?}"));
9218                    }
9219                    ops.push(OutboxOp {
9220                        upsert: false,
9221                        table,
9222                        row_id,
9223                        base_version,
9224                        values: None,
9225                    });
9226                }
9227            }
9228        }
9229        self.record_outbox_commit(ops)
9230    }
9231
9232    fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
9233        let commit = OutboxCommit {
9234            client_commit_id: uuid::Uuid::new_v4().to_string(),
9235            ops,
9236        };
9237        self.begin_observation("syncular_mutation")?;
9238        let mut batch = ChangeAccumulator::default();
9239        for op in &commit.ops {
9240            let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
9241            if let Some(values) = &op.values {
9242                if let Some(table) = self.schema.table(&op.table) {
9243                    for scope in &table.scope_variables {
9244                        if let Some(Value::String(value)) = values.get(&scope.column) {
9245                            batch.scope(&op.table, format!("{}:{value}", scope.prefix));
9246                            precise = true;
9247                        }
9248                    }
9249                }
9250            }
9251            if !precise {
9252                batch.table(&op.table);
9253            }
9254        }
9255        if let Err(error) = self.persist_outbox_insert(&commit) {
9256            self.rollback_observation("syncular_mutation");
9257            return Err(error);
9258        }
9259        let was_dirty = self.overlay_dirty.snapshot();
9260        // A clean overlay contains the FIFO fold of every previous commit, so
9261        // the new commit applies over it. §7.1: an operation that does not
9262        // apply (a secondary unique collision) fails the whole commit; the
9263        // savepoint rollback removes the outbox entry and every visible write.
9264        if self.overlay_dirty.get() {
9265            self.rebuild_overlay();
9266        }
9267        if let Some(error) = commit
9268            .ops
9269            .iter()
9270            .find_map(|op| self.apply_outbox_op(op).err())
9271        {
9272            self.rollback_observation("syncular_mutation");
9273            self.overlay_dirty.restore(was_dirty.clone());
9274            return Err(error);
9275        }
9276        let id = commit.client_commit_id.clone();
9277        self.outbox.push(commit);
9278        batch.status = true;
9279        if let Err(error) = self.finish_observation("syncular_mutation", batch) {
9280            self.rollback_observation("syncular_mutation");
9281            self.outbox.pop();
9282            self.overlay_dirty.restore(was_dirty.clone());
9283            return Err(error);
9284        }
9285        Ok(id)
9286    }
9287
9288    /// Record one §6.1 sparse upsert: the primary key plus the supplied
9289    /// non-scope columns are present; every other column is absent and stays
9290    /// untouched on the server and in the local overlay. An absent local base
9291    /// refuses the whole batch with sync.row_missing before enqueueing (§7.1).
9292    pub fn patch(
9293        &mut self,
9294        table: &str,
9295        row_id: &str,
9296        partial: Map<String, Value>,
9297        base_version: Option<i64>,
9298    ) -> Result<String, String> {
9299        let operation = self.prepare_patch(table, row_id, partial, base_version)?;
9300        self.record_outbox_commit(vec![operation])
9301    }
9302
9303    fn prepare_patch(
9304        &self,
9305        table: &str,
9306        row_id: &str,
9307        partial: Map<String, Value>,
9308        base_version: Option<i64>,
9309    ) -> Result<OutboxOp, String> {
9310        let schema_table = self
9311            .schema
9312            .table(table)
9313            .ok_or_else(|| format!("unknown table {table:?}"))?;
9314        let partial = normalize_values_casing(schema_table, partial)?;
9315        if let Some(pk) = partial.get(&schema_table.primary_key) {
9316            if pk.as_str() != Some(row_id) {
9317                return Err(format!(
9318                    "sync.invalid_request: table {table:?}: patch cannot change the primary key"
9319                ));
9320            }
9321        }
9322        // Existence reads only the key. Locked encrypted values stay opaque;
9323        // decoding the complete row here would reject a valid plain patch.
9324        let exists: bool = self
9325            .conn
9326            .query_row(
9327                &format!(
9328                    "SELECT EXISTS(SELECT 1 FROM {} WHERE {})",
9329                    visible_table(table),
9330                    row_id_predicate(schema_table)
9331                ),
9332                rusqlite::params![row_id],
9333                |row| row.get(0),
9334            )
9335            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9336        if !exists {
9337            return Err("sync.row_missing: a sparse patch requires a local row".to_owned());
9338        }
9339        // §3.4 rule 5 / §6.2: scope columns are immutable on update. The
9340        // server accepts a present scope column whose value equals the
9341        // stored row and applies it as a no-op, so a decoded envelope
9342        // round-trips. The local row is the only value the client can prove
9343        // equality against; when the row is absent locally the client cannot
9344        // prove equality against the server's stored row and fails closed.
9345        // A primary key that is also a scope column needs no stored-row
9346        // read: the primary key in a sparse payload is by construction the
9347        // row id being patched (§6.1), so its value is proven equal already.
9348        for scope in &schema_table.scope_variables {
9349            if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
9350                continue;
9351            }
9352            let Some(column) = schema_table
9353                .columns
9354                .iter()
9355                .find(|column| column.name == scope.column)
9356            else {
9357                continue;
9358            };
9359            let stored = self
9360                .conn
9361                .query_row(
9362                    &format!(
9363                        "SELECT {} FROM {} WHERE {}",
9364                        quote_ident(&scope.column),
9365                        visible_table(&schema_table.name),
9366                        row_id_predicate(schema_table)
9367                    ),
9368                    rusqlite::params![row_id],
9369                    |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
9370                )
9371                .ok();
9372            // Decode the supplied value through the same column-type path the
9373            // rest of the patch encoding uses, so a value that only equals
9374            // the stored one in another number representation still compares
9375            // equal.
9376            let incoming = json_to_column_value(column, partial.get(&scope.column))
9377                .ok()
9378                .map(|value| column_value_to_json(&value));
9379            // A stored blob never equals a patch value, matching the server's
9380            // scope-column comparison (§3.4): bytes are unstorable as scope
9381            // values.
9382            let equal = stored.as_ref().is_some_and(|stored| {
9383                stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
9384            });
9385            if !equal {
9386                return Err(format!(
9387                    "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
9388                    scope.column
9389                ));
9390            }
9391        }
9392        let mut values = partial;
9393        // Presence-set semantics: a proven-equal scope column is dropped, so
9394        // the patch leaves the stored value untouched (§6.2).
9395        for scope in &schema_table.scope_variables {
9396            values.remove(&scope.column);
9397        }
9398        values.insert(
9399            schema_table.primary_key.clone(),
9400            Value::from(row_id.to_owned()),
9401        );
9402        // §5.11: structural validation only; an absent key-id selector is
9403        // resolved at the push seam from the stored local row, and a
9404        // key-selection or unknown-key failure becomes a durable
9405        // `client.encrypt_failed` rejection there (§10.3), never an
9406        // author-time error.
9407        self.validate_author_encode(schema_table, row_id, &values)?;
9408        Ok(OutboxOp {
9409            upsert: true,
9410            table: table.to_owned(),
9411            row_id: row_id.to_owned(),
9412            base_version,
9413            values: Some(values),
9414        })
9415    }
9416
9417    /// §5.11 key-id fallback: the stored local row's selector value as a
9418    /// positional row (only the selector slot filled). Returns None when no
9419    /// selector is configured, the row is locally absent, or the stored
9420    /// value is not a usable key id.
9421    fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
9422        let selector = self.encryption.key_id_columns.get(&table.name)?;
9423        let index = table
9424            .columns
9425            .iter()
9426            .position(|column| &column.name == selector)?;
9427        let sql = format!(
9428            "SELECT {} FROM {} WHERE {}",
9429            quote_ident(selector),
9430            visible_table(&table.name),
9431            row_id_predicate(table)
9432        );
9433        let key: Option<String> = self
9434            .conn
9435            .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
9436            .ok()?;
9437        let key = key.filter(|key| !key.is_empty())?;
9438        let mut fallback: Row = vec![None; table.columns.len()];
9439        fallback[index] = Some(ColumnValue::String(key));
9440        Some(fallback)
9441    }
9442
9443    /// §5.11/§10.3 author-time encode check: a structural failure (a
9444    /// non-nullable NULL, a value the codec rejects) is an author-time
9445    /// error; a key-selection or unknown-key failure is deferred to the push
9446    /// seam, where it becomes a durable `client.encrypt_failed` rejection.
9447    fn validate_author_encode(
9448        &self,
9449        table: &TableSchema,
9450        row_id: &str,
9451        values: &Map<String, Value>,
9452    ) -> Result<(), String> {
9453        match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
9454            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
9455            result => result.map(|_| ()),
9456        }
9457    }
9458
9459    /// §5.11/§10.3: drop every pending commit whose push encode fails with
9460    /// `client.encrypt_failed` (an unresolvable key id or an unknown key) and
9461    /// raise one durable rejection per commit. `sync()` never aborts for an
9462    /// encode failure. Mirrors §7.4.4's `drop_incompatible_outbox`, including
9463    /// undoing the dropped commit's purely-optimistic rows.
9464    fn drop_unencodable_outbox(&mut self) -> Result<bool, String> {
9465        if !self
9466            .schema
9467            .tables
9468            .iter()
9469            .any(|table| table.has_encrypted_columns())
9470        {
9471            return Ok(false);
9472        }
9473        let failures = self
9474            .outbox
9475            .iter()
9476            .filter_map(|commit| {
9477                commit
9478                    .ops
9479                    .iter()
9480                    .find_map(|op| {
9481                        let values = op.values.as_ref().filter(|_| op.upsert)?;
9482                        let table = self.schema.table(&op.table)?;
9483                        let fallback = self.stored_key_fallback(table, &op.row_id);
9484                        match encode_sparse_row_json(
9485                            table,
9486                            &op.row_id,
9487                            values,
9488                            &self.encryption,
9489                            fallback.as_ref(),
9490                        ) {
9491                            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Some(error),
9492                            _ => None,
9493                        }
9494                    })
9495                    .map(|error| (commit.clone(), error))
9496            })
9497            .collect::<Vec<_>>();
9498        if failures.is_empty() {
9499            return Ok(false);
9500        }
9501        for (commit, message) in failures {
9502            if self.retain_failed_commits {
9503                self.retain_failed_commit(&commit.client_commit_id, &commit.ops)?;
9504            }
9505            let rejection = RejectionRecord {
9506                client_commit_id: commit.client_commit_id.clone(),
9507                op_index: 0,
9508                code: ENCRYPT_FAILED_CODE.to_owned(),
9509                message,
9510                retryable: false,
9511                details: None,
9512                operation: commit.ops.first().map(CommitOperation::from),
9513            };
9514            self.persist_commit_outcome(
9515                &commit.client_commit_id,
9516                CommitOutcomeStatus::Rejected,
9517                &[CommitOperationOutcome::Error {
9518                    rejection: rejection.clone(),
9519                }],
9520                Some(&commit.ops),
9521            )?;
9522            self.delete_outbox_persisted(&commit.client_commit_id)?;
9523            self.outbox
9524                .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
9525            self.rejections.push(rejection);
9526        }
9527        self.prune_commit_outcomes()?;
9528        self.rebuild_overlay_if_dirty();
9529        Ok(true)
9530    }
9531
9532    pub fn pending_commit_ids(&self) -> Vec<String> {
9533        self.outbox
9534            .iter()
9535            .map(|c| c.client_commit_id.clone())
9536            .collect()
9537    }
9538
9539    /// §6.1: the sparse push payload bytes of every pending upsert operation,
9540    /// FIFO — exactly what the next push round would put on the wire. Used by
9541    /// conformance B.21(h) to pin cross-core byte identity.
9542    pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
9543        let mut payloads = Vec::new();
9544        for commit in &self.outbox {
9545            for op in &commit.ops {
9546                let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
9547                    continue;
9548                };
9549                let Some(table) = self.schema.table(&op.table) else {
9550                    continue;
9551                };
9552                let fallback = self.stored_key_fallback(table, &op.row_id);
9553                if let Ok(payload) = encode_sparse_row_json(
9554                    table,
9555                    &op.row_id,
9556                    values,
9557                    &self.encryption,
9558                    fallback.as_ref(),
9559                ) {
9560                    payloads.push(payload);
9561                }
9562            }
9563        }
9564        payloads
9565    }
9566
9567    pub fn conflicts(&self) -> &[ConflictRecord] {
9568        &self.conflicts
9569    }
9570
9571    pub fn rejections(&self) -> &[RejectionRecord] {
9572        &self.rejections
9573    }
9574
9575    pub fn resolve_commit_outcome(
9576        &mut self,
9577        input: ResolveCommitOutcomeInput,
9578    ) -> Result<CommitOutcome, String> {
9579        let current = self
9580            .commit_outcome(&input.client_commit_id)?
9581            .ok_or_else(|| {
9582                format!(
9583                    "sync.outcome_not_found: no durable outcome exists for {:?}",
9584                    input.client_commit_id
9585                )
9586            })?;
9587        if current.resolution != CommitOutcomeResolution::Active {
9588            return Ok(current);
9589        }
9590        if input.resolution == CommitOutcomeResolution::Active {
9591            return Err("sync.invalid_request: resolution must leave active state".to_owned());
9592        }
9593        match input.resolution {
9594            CommitOutcomeResolution::Superseded => {
9595                let replacement = input
9596                    .replacement_client_commit_id
9597                    .as_deref()
9598                    .filter(|value| !value.is_empty() && *value != input.client_commit_id)
9599                    .ok_or_else(|| {
9600                        "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
9601                            .to_owned()
9602                    })?;
9603                let _ = replacement;
9604            }
9605            _ if input.replacement_client_commit_id.is_some() => {
9606                return Err(
9607                    "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
9608                        .to_owned(),
9609                );
9610            }
9611            _ => {}
9612        }
9613        let allowed = match current.status {
9614            CommitOutcomeStatus::Conflict => matches!(
9615                input.resolution,
9616                CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
9617            ),
9618            CommitOutcomeStatus::Rejected => {
9619                matches!(
9620                    input.resolution,
9621                    CommitOutcomeResolution::Superseded
9622                        | CommitOutcomeResolution::ResolvedKeepServer
9623                )
9624            }
9625            CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
9626                input.resolution == CommitOutcomeResolution::Dismissed
9627            }
9628        };
9629        if !allowed {
9630            return Err(format!(
9631                "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
9632                input.resolution, current.status
9633            ));
9634        }
9635
9636        self.begin_observation("syncular_outcome_resolution")?;
9637        let previous_failed = self.failed_commits.clone();
9638        let previous_conflicts = self.conflicts.clone();
9639        let previous_rejections = self.rejections.clone();
9640        let result = (|| {
9641            self.conn
9642                .execute(
9643                    "UPDATE _syncular_commit_outcomes
9644                        SET resolution = ?1, resolved_at_ms = ?2,
9645                            replacement_client_commit_id = ?3
9646                      WHERE client_commit_id = ?4 AND resolution = 'active'",
9647                    rusqlite::params![
9648                        Self::outcome_resolution_name(input.resolution),
9649                        self.clock_now_ms(),
9650                        input.replacement_client_commit_id,
9651                        input.client_commit_id
9652                    ],
9653                )
9654                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9655            let resolved = self
9656                .commit_outcome(&current.client_commit_id)?
9657                .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
9658            self.conn
9659                .execute(
9660                    "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
9661                    rusqlite::params![input.client_commit_id],
9662                )
9663                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9664            for (commit, _) in &self.failed_commits {
9665                if commit.client_commit_id == input.client_commit_id {
9666                    for operation in &commit.ops {
9667                        self.overlay_dirty.table(&operation.table);
9668                    }
9669                }
9670            }
9671            self.failed_commits
9672                .retain(|(commit, _)| commit.client_commit_id != input.client_commit_id);
9673            self.prune_commit_outcomes()?;
9674            Ok(resolved)
9675        })();
9676        let resolved = match result {
9677            Ok(outcome) => outcome,
9678            Err(error) => {
9679                self.rollback_observation("syncular_outcome_resolution");
9680                self.failed_commits = previous_failed;
9681                self.conflicts = previous_conflicts;
9682                self.rejections = previous_rejections;
9683                return Err(error);
9684            }
9685        };
9686        self.conflicts
9687            .retain(|record| record.client_commit_id != current.client_commit_id);
9688        self.rejections
9689            .retain(|record| record.client_commit_id != current.client_commit_id);
9690        let mut batch = ChangeAccumulator {
9691            conflicts: current.status == CommitOutcomeStatus::Conflict,
9692            rejections: current.status == CommitOutcomeStatus::Rejected,
9693            outcomes: true,
9694            ..ChangeAccumulator::default()
9695        };
9696        if let Some(operations) = &current.operations {
9697            for operation in operations {
9698                batch.table(&operation.table);
9699            }
9700        }
9701        self.rebuild_overlay_if_dirty();
9702        if let Err(error) = self.finish_observation("syncular_outcome_resolution", batch) {
9703            self.rollback_observation("syncular_outcome_resolution");
9704            self.failed_commits = previous_failed;
9705            self.conflicts = previous_conflicts;
9706            self.rejections = previous_rejections;
9707            return Err(error);
9708        }
9709        Ok(resolved)
9710    }
9711
9712    pub fn schema_floor(&self) -> Option<&SchemaFloor> {
9713        self.schema_floor.as_ref()
9714    }
9715
9716    /// §7.3.5: the client's opaque auth-lease state, if any.
9717    pub fn lease_state(&self) -> Option<&LeaseState> {
9718        self.lease_state.as_ref()
9719    }
9720
9721    /// §7.3.5: record a request-level lease error (stop-and-surface). Only
9722    /// the two lease codes set it; other errors leave leaseState untouched.
9723    fn record_lease_error(&mut self, code: &str) {
9724        if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
9725            return;
9726        }
9727        let mut next = self.lease_state.clone().unwrap_or_default();
9728        next.error_code = Some(code.to_owned());
9729        self.set_lease_state(Some(next));
9730    }
9731
9732    fn set_lease_state(&mut self, next: Option<LeaseState>) {
9733        if self.lease_state == next {
9734            return;
9735        }
9736        if self.begin_observation("syncular_lease").is_err() {
9737            return;
9738        }
9739        self.lease_state = next;
9740        if let Some(lease) = &self.lease_state {
9741            if let Ok(json) = serde_json::to_string(lease) {
9742                self.set_meta(LEASE_STATE_KEY, &json);
9743            }
9744        } else {
9745            self.delete_meta(LEASE_STATE_KEY);
9746        }
9747        let batch = ChangeAccumulator {
9748            status: true,
9749            ..ChangeAccumulator::default()
9750        };
9751        if self.finish_observation("syncular_lease", batch).is_err() {
9752            self.rollback_observation("syncular_lease");
9753        }
9754    }
9755
9756    fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
9757        if self.schema_floor == next {
9758            return;
9759        }
9760        if self.begin_observation("syncular_schema_floor").is_err() {
9761            return;
9762        }
9763        self.schema_floor = next;
9764        self.stopped = self.schema_floor.is_some();
9765        if let Some(floor) = &self.schema_floor {
9766            if let Ok(json) = serde_json::to_string(floor) {
9767                self.set_meta(SCHEMA_FLOOR_KEY, &json);
9768            }
9769        } else {
9770            self.delete_meta(SCHEMA_FLOOR_KEY);
9771        }
9772        let batch = ChangeAccumulator {
9773            status: true,
9774            ..ChangeAccumulator::default()
9775        };
9776        if self
9777            .finish_observation("syncular_schema_floor", batch)
9778            .is_err()
9779        {
9780            self.rollback_observation("syncular_schema_floor");
9781        }
9782    }
9783
9784    fn set_upgrading(&mut self, value: bool) {
9785        if self.upgrading == value {
9786            return;
9787        }
9788        if self.begin_observation("syncular_upgrading").is_err() {
9789            return;
9790        }
9791        self.upgrading = value;
9792        let batch = ChangeAccumulator {
9793            status: true,
9794            ..ChangeAccumulator::default()
9795        };
9796        if self
9797            .finish_observation("syncular_upgrading", batch)
9798            .is_err()
9799        {
9800            self.rollback_observation("syncular_upgrading");
9801        }
9802    }
9803
9804    pub fn sync_needed(&self) -> bool {
9805        self.sync_needed
9806    }
9807
9808    pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
9809        let sub = self.subs.iter().find(|s| s.id == id)?;
9810        Some(SubscriptionStateView {
9811            id: sub.id.clone(),
9812            table: sub.table.clone(),
9813            status: sub.state.name().to_owned(),
9814            cursor: sub.cursor,
9815            has_resume_token: sub.bootstrap_state.is_some(),
9816            effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
9817            reason_code: sub.reason_code.clone(),
9818        })
9819    }
9820
9821    pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
9822        let schema_table = self
9823            .schema
9824            .table(table)
9825            .ok_or_else(|| format!("unknown table {table:?}"))?;
9826        let sql = format!(
9827            "SELECT * FROM {} ORDER BY {} ASC",
9828            visible_table(table),
9829            quote_ident(&schema_table.primary_key)
9830        );
9831        let mut stmt = self
9832            .conn
9833            .prepare(&sql)
9834            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9835        let mut rows = stmt
9836            .query([])
9837            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9838        let mut out = Vec::new();
9839        while let Some(row) = rows
9840            .next()
9841            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
9842        {
9843            let mut values = Map::new();
9844            for (i, column) in schema_table.columns.iter().enumerate() {
9845                let value = row
9846                    .get_ref(i)
9847                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9848                values.insert(column.name.clone(), sql_ref_to_json(column, value));
9849            }
9850            let version: i64 = row
9851                .get(schema_table.columns.len())
9852                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9853            let row_id = match values.get(&schema_table.primary_key) {
9854                Some(Value::String(s)) => s.clone(),
9855                Some(Value::Number(n)) => n.to_string(),
9856                Some(Value::Bool(b)) => b.to_string(),
9857                other => format!("{}", other.cloned().unwrap_or(Value::Null)),
9858            };
9859            out.push(RowState {
9860                row_id,
9861                version,
9862                values,
9863            });
9864        }
9865        Ok(out)
9866    }
9867
9868    // -- §5.10.5 native CRDT (the `crdt-yjs` feature) --------------------------
9869    //
9870    // The Rust face of the §5.10.4 client model: a local crdt edit loads the
9871    // current stored (server-merged ⊕ pending-overlay) column bytes, applies
9872    // the op with `yrs`, re-encodes the whole doc state, and records a
9873    // baseVersion-less sparse upsert carrying only the primary key and the
9874    // crdt column (§5.10.3, §6.6). No local merge — merging is server-side;
9875    // the overlay re-materializes the edit immediately (optimistic apply,
9876    // §7.1) and the server-merged bytes arrive on the next pull, idempotently.
9877    // Byte-compatible with `@syncular/crdt-yjs`.
9878
9879    /// The current stored value of a `crdt` column for one row — the visible
9880    /// (optimistic) bytes, or `None` when the row is absent or the column is
9881    /// NULL (the empty document, §5.10.1). Errors if the column is not a
9882    /// `crdt` column (guards the app against a typo'd column name).
9883    #[cfg(feature = "crdt-yjs")]
9884    fn crdt_column_bytes(
9885        &self,
9886        table: &str,
9887        row_id: &str,
9888        column: &str,
9889    ) -> Result<Option<Vec<u8>>, String> {
9890        let schema_table = self
9891            .schema
9892            .table(table)
9893            .ok_or_else(|| format!("unknown table {table:?}"))?;
9894        let col = schema_table
9895            .columns
9896            .iter()
9897            .find(|c| c.name == column)
9898            .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
9899        if col.ty != ColumnType::Crdt {
9900            return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
9901        }
9902        let sql = format!(
9903            "SELECT {} FROM {} WHERE {}",
9904            quote_ident(column),
9905            visible_table(table),
9906            row_id_predicate(schema_table)
9907        );
9908        let bytes: Option<Vec<u8>> = self
9909            .conn
9910            .prepare_cached(&sql)
9911            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
9912            .query_row(rusqlite::params![row_id], |row| {
9913                row.get::<_, Option<Vec<u8>>>(0)
9914            })
9915            .map_err(|e| match e {
9916                rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
9917                other => other.to_string(),
9918            })?;
9919        Ok(bytes)
9920    }
9921
9922    /// §5.10.4 materialize: the collaborative text of a `crdt` column, decoded
9923    /// from the stored bytes with `yrs` — `YjsColumn.text(name).toString()`.
9924    /// An absent row / NULL column is the empty document (empty string).
9925    #[cfg(feature = "crdt-yjs")]
9926    pub fn crdt_text(
9927        &self,
9928        table: &str,
9929        row_id: &str,
9930        column: &str,
9931        name: &str,
9932    ) -> Result<String, String> {
9933        let bytes = self
9934            .crdt_column_bytes(table, row_id, column)?
9935            .unwrap_or_default();
9936        crate::crdt::text(&bytes, name)
9937    }
9938
9939    /// §5.10.4 push-an-update: apply a text insert to a `crdt` column and
9940    /// record the resulting crdt-only sparse operation. Returns the enqueued
9941    /// `clientCommitId`.
9942    #[cfg(feature = "crdt-yjs")]
9943    pub fn crdt_insert_text(
9944        &mut self,
9945        table: &str,
9946        row_id: &str,
9947        column: &str,
9948        name: &str,
9949        index: u32,
9950        value: &str,
9951    ) -> Result<String, String> {
9952        let current = self
9953            .crdt_column_bytes(table, row_id, column)?
9954            .unwrap_or_default();
9955        let update = crate::crdt::insert_text(&current, name, index, value)?;
9956        self.crdt_push_update(table, row_id, column, &update)
9957    }
9958
9959    /// §5.10.4 push-an-update: apply a text delete to a `crdt` column and
9960    /// record the resulting crdt-only sparse operation. Returns the enqueued
9961    /// `clientCommitId`.
9962    #[cfg(feature = "crdt-yjs")]
9963    pub fn crdt_delete_text(
9964        &mut self,
9965        table: &str,
9966        row_id: &str,
9967        column: &str,
9968        name: &str,
9969        index: u32,
9970        len: u32,
9971    ) -> Result<String, String> {
9972        let current = self
9973            .crdt_column_bytes(table, row_id, column)?
9974            .unwrap_or_default();
9975        let update = crate::crdt::delete_text(&current, name, index, len)?;
9976        self.crdt_push_update(table, row_id, column, &update)
9977    }
9978
9979    /// §5.10.4 generic escape hatch: apply an arbitrary Yjs update onto a
9980    /// `crdt` column's current state and record the resulting crdt-only sparse
9981    /// operation. The app authored the update with its own `yrs` model.
9982    /// Returns the enqueued `clientCommitId`.
9983    #[cfg(feature = "crdt-yjs")]
9984    pub fn crdt_apply_update(
9985        &mut self,
9986        table: &str,
9987        row_id: &str,
9988        column: &str,
9989        update: &[u8],
9990    ) -> Result<String, String> {
9991        let current = self
9992            .crdt_column_bytes(table, row_id, column)?
9993            .unwrap_or_default();
9994        let next = crate::crdt::apply_update(&current, update)?;
9995        self.crdt_push_update(table, row_id, column, &next)
9996    }
9997
9998    /// Shared tail of the crdt edit methods: record a §6.1 sparse crdt-only
9999    /// upsert — the primary key plus the crdt column, no `baseVersion` — and
10000    /// enqueue it. The server merges the crdt column and leaves every other
10001    /// column untouched (§5.10.3), so a baseVersion-less crdt edit cannot
10002    /// clobber a concurrent edit to another column. A locally absent row
10003    /// records the same partial operation; the server answers per §6.2.
10004    #[cfg(feature = "crdt-yjs")]
10005    fn crdt_push_update(
10006        &mut self,
10007        table: &str,
10008        row_id: &str,
10009        column: &str,
10010        crdt_bytes: &[u8],
10011    ) -> Result<String, String> {
10012        let mut bytes_obj = Map::new();
10013        bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
10014        let mut partial = Map::new();
10015        partial.insert(column.to_owned(), Value::Object(bytes_obj));
10016        self.patch(table, row_id, partial, None)
10017    }
10018
10019    /// Run an arbitrary read-only SQL query against the local database and
10020    /// return each row as a `column-name → JSON value` map. This is the seam
10021    /// the React `useSyncQuery` live-query API needs (it takes app-authored
10022    /// SQL over the visible tables/views, not a fixed table read like
10023    /// [`read_rows`]).
10024    ///
10025    /// Bound `params` are the driver value forms: JSON strings/numbers/bools/
10026    /// null bind directly; a `{"$bytes": hex}` object binds as a BLOB — the
10027    /// same envelope the command surface uses everywhere else. Output BLOB
10028    /// columns come back as `{"$bytes": hex}` to round-trip cleanly.
10029    ///
10030    /// The result column typing is dynamic (SQLite's stored affinity), because
10031    /// arbitrary SQL can alias, join, and compute — there is no schema column
10032    /// to consult per output cell, unlike [`read_rows`].
10033    pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
10034        query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
10035    }
10036
10037    /// Repository benchmark and conformance access, present only with `bench-internals`.
10038    #[cfg(feature = "bench-internals")]
10039    #[doc(hidden)]
10040    pub fn benchmark_connection(&mut self) -> &mut Connection {
10041        &mut self.conn
10042    }
10043
10044    /// Private benchmark intervals; absent from ordinary builds and diagnostics.
10045    #[cfg(feature = "bench-internals")]
10046    #[doc(hidden)]
10047    pub fn benchmark_phases(
10048        &self,
10049        enabled: Option<bool>,
10050        reset: bool,
10051    ) -> Result<Option<Value>, String> {
10052        self.benchmark_phases.configure(enabled, reset)?;
10053        Ok(self.benchmark_phases.snapshot())
10054    }
10055
10056    /// Rows, coverage, and local revision from one SQLite read snapshot.
10057    /// §7.5 atomic snapshot read. An `owner` records a failure in
10058    /// `diagnostics_snapshot().query_failures` until its next successful read.
10059    pub fn query_snapshot(
10060        &mut self,
10061        sql: &str,
10062        params: &[QueryValue],
10063        coverage: &[WindowCoverage],
10064        owner: Option<QueryOwner<'_>>,
10065    ) -> Result<QuerySnapshot, String> {
10066        let result = snapshot_connection(&self.conn, sql, params, coverage);
10067        if let Some(owner) = owner {
10068            self.record_query_read(owner, result.as_ref().err());
10069        }
10070        result.map_err(|failure| failure.to_string())
10071    }
10072
10073    /// §7.6: record the outcome of one owned snapshot read, including reads a
10074    /// host ran on a separate connection. A success removes the owner's entry;
10075    /// an identical failure leaves it unchanged; a changed failure moves last.
10076    pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
10077        let position = self
10078            .query_failures
10079            .iter()
10080            .position(|entry| entry.id == owner.id);
10081        let Some(failure) = failure else {
10082            if let Some(index) = position {
10083                self.query_failures.remove(index);
10084            }
10085            return;
10086        };
10087        let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
10088            .into_iter()
10089            .map(str::to_owned)
10090            .collect();
10091        let code = failure.code.unwrap_or("client.query_failed").to_owned();
10092        if position.is_some_and(|index| {
10093            let entry = &self.query_failures[index];
10094            entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
10095        }) {
10096            return;
10097        }
10098        if let Some(index) = position {
10099            self.query_failures.remove(index);
10100        }
10101        self.query_failures.push(DiagnosticQueryFailure {
10102            id: owner.id.to_owned(),
10103            tables,
10104            code,
10105            sqlite_code: failure.sqlite_code,
10106            at_ms: self.clock_now_ms(),
10107        });
10108        if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
10109            self.query_failures.remove(0);
10110        }
10111    }
10112
10113    // -- request building ---------------------------------------------------------
10114
10115    fn build_request(&self, url_capable: bool) -> (Message, RequestMeta) {
10116        #[cfg(feature = "bench-internals")]
10117        let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
10118        let log_epoch = self.get_meta(LOG_EPOCH_KEY);
10119        let mut frames = vec![Frame::ReqHeader {
10120            client_id: self.client_id.clone(),
10121            schema_version: self.schema.version,
10122            log_epoch: log_epoch.clone(),
10123        }];
10124        let mut pushed_ids = Vec::new();
10125        let mut ops_in_request = 0usize;
10126        let mut deferred_commits = 0usize;
10127        #[cfg(feature = "bench-internals")]
10128        let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
10129        for (index, commit) in self
10130            .outbox
10131            .iter()
10132            .take(if log_epoch.is_some() {
10133                self.outbox.len()
10134            } else {
10135                0
10136            })
10137            .enumerate()
10138        {
10139            // §6.1 splitBatch: stop at the operation cap — commits apply in
10140            // order, so everything from the first non-fitting commit on is
10141            // deferred to the next round. A single over-cap commit still goes
10142            // alone (commits are atomic and cannot be split).
10143            if ops_in_request > 0 && ops_in_request + commit.ops.len() > PUSH_OPS_PER_REQUEST {
10144                deferred_commits = self.outbox.len() - index;
10145                break;
10146            }
10147            ops_in_request += commit.ops.len();
10148            let operations = commit
10149                .ops
10150                .iter()
10151                .map(|op| {
10152                    let payload = op.values.as_ref().and_then(|values| {
10153                        let table = self.schema.table(&op.table)?;
10154                        // §0: outbox entries encode at send time with the
10155                        // current codec (validated at mutate()). §6.1: the
10156                        // values map's keys are the sparse presence set.
10157                        // §5.11: encrypted columns are encrypted here, with
10158                        // an absent key-id selector read from the stored row.
10159                        // `sync_inner` runs `drop_unencodable_outbox` first,
10160                        // so an encrypt failure cannot reach this encode; the
10161                        // author seam validated every other failure.
10162                        let fallback = self.stored_key_fallback(table, &op.row_id);
10163                        encode_sparse_row_json(
10164                            table,
10165                            &op.row_id,
10166                            values,
10167                            &self.encryption,
10168                            fallback.as_ref(),
10169                        )
10170                        .ok()
10171                    });
10172                    ssp2::model::Operation {
10173                        table: op.table.clone(),
10174                        row_id: op.row_id.clone(),
10175                        op: if op.upsert { Op::Upsert } else { Op::Delete },
10176                        base_version: op.base_version,
10177                        payload,
10178                    }
10179                })
10180                .collect();
10181            frames.push(Frame::PushCommit {
10182                client_commit_id: commit.client_commit_id.clone(),
10183                operations,
10184            });
10185            pushed_ids.push(commit.client_commit_id.clone());
10186        }
10187        #[cfg(feature = "bench-internals")]
10188        drop(outbox_phase);
10189        // §4.2/§5.4: bit 3 is advertised iff the transport can fetch a
10190        // bare URL — capability negotiation, decided per transport.
10191        let accept = self.limits.accept.unwrap_or(if url_capable {
10192            DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
10193        } else {
10194            DEFAULT_ACCEPT
10195        });
10196        frames.push(Frame::PullHeader {
10197            limit_commits: self.limits.limit_commits.unwrap_or(0),
10198            limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
10199            max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
10200            accept,
10201        });
10202        let mut fresh = Vec::new();
10203        for sub in &self.subs {
10204            if sub.state != SubState::Active {
10205                continue;
10206            }
10207            let mut scopes = sub.requested.clone();
10208            sort_scope_map(&mut scopes);
10209            frames.push(Frame::Subscription {
10210                id: sub.id.clone(),
10211                table: sub.table.clone(),
10212                scopes,
10213                params: sub.params.clone().map(RawJson),
10214                cursor: sub.cursor,
10215                bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
10216            });
10217            fresh.push((
10218                sub.id.clone(),
10219                sub.cursor < 0 && sub.bootstrap_state.is_none(),
10220            ));
10221        }
10222        let message = Message {
10223            wire_version: WIRE_VERSION,
10224            msg_kind: MsgKind::Request,
10225            frames,
10226        };
10227        (
10228            message,
10229            RequestMeta {
10230                pushed_ids,
10231                fresh,
10232                accept,
10233                deferred_commits,
10234            },
10235        )
10236    }
10237
10238    // -- sync -------------------------------------------------------------------
10239
10240    /// Cloneable observation handle; no client lock is needed to read it.
10241    pub fn progress(&self) -> ProgressObserver {
10242        self.progress.clone()
10243    }
10244
10245    pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
10246        let prepared = match self.prepare_sync_round(transport.supports_url_fetch()) {
10247            Ok(prepared) => prepared,
10248            Err(outcome) => return *outcome,
10249        };
10250        let mut next = prepared;
10251        loop {
10252            match self.apply_sync_round(next.exchange(transport)) {
10253                crate::AppliedSyncRound::Continue(prepared) => next = prepared,
10254                crate::AppliedSyncRound::Complete {
10255                    outcome, controls, ..
10256                } => {
10257                    if self.transport_enabled {
10258                        for text in controls {
10259                            let _ = transport.realtime_send(&text);
10260                        }
10261                    } else {
10262                        self.disconnect_realtime(transport);
10263                    }
10264                    return outcome;
10265                }
10266            }
10267        }
10268    }
10269
10270    fn finish_sync_round(&mut self, started_at_ms: i64, mut outcome: SyncOutcome) -> SyncOutcome {
10271        if let SyncOutcome::Failed {
10272            error_code,
10273            message,
10274            details,
10275        } = &mut outcome
10276        {
10277            if let Some(failure) = self.storage_failure.borrow().as_ref() {
10278                let code = failure
10279                    .code
10280                    .expect("retained storage failures are classified");
10281                *error_code = code.to_owned();
10282                *message = match code {
10283                    "client.storage_full" => "local SQLite storage is full",
10284                    "client.storage_io" => "local SQLite storage I/O failed",
10285                    "client.storage_corrupt" => "local SQLite storage is corrupt",
10286                    _ => unreachable!("unrecognized classified storage failure"),
10287                }
10288                .to_owned();
10289                *details = failure.details();
10290            }
10291        }
10292        let retry_delay_ms = self.round_retry_delay_ms;
10293        self.progress.update(|p| match &outcome {
10294            SyncOutcome::Ok(report) if report.failed.is_empty() => {
10295                p.state = ProgressState::Complete
10296            }
10297            SyncOutcome::Ok(_) => {
10298                p.state = ProgressState::Failed;
10299                p.error_code = Some("sync.scope_revoked".into());
10300            }
10301            SyncOutcome::Failed { error_code, .. } => {
10302                p.state = ProgressState::Failed;
10303                p.error_code = Some(Self::diagnostic_code(error_code));
10304                p.retry_delay_ms = retry_delay_ms;
10305            }
10306            SyncOutcome::RealtimeUnavailable { .. } => {
10307                p.state = ProgressState::Failed;
10308                p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
10309                p.retry_delay_ms = retry_delay_ms;
10310            }
10311        });
10312        let completed_at_ms = self.clock_now_ms();
10313        self.last_round = Some(match &outcome {
10314            SyncOutcome::Ok(report) => DiagnosticLastRound {
10315                status: "succeeded".to_owned(),
10316                started_at_ms,
10317                completed_at_ms,
10318                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
10319                counters: Some(DiagnosticRoundCounters {
10320                    pushed: report.pushed,
10321                    applied: report.applied.len(),
10322                    rejected: report.rejected.len(),
10323                    retryable: report.retryable.len(),
10324                    conflicts: report.conflicts,
10325                    commits_applied: report.commits_applied,
10326                    segment_rows_applied: report.segment_rows_applied,
10327                    bootstrapping: report.bootstrapping.len(),
10328                    resets: report.resets.len(),
10329                    revoked: report.revoked.len(),
10330                    failed: report.failed.len(),
10331                    deferred_commits: report.deferred_commits,
10332                }),
10333                error_code: None,
10334            },
10335            SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
10336                status: "failed".to_owned(),
10337                started_at_ms,
10338                completed_at_ms,
10339                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
10340                counters: None,
10341                error_code: Some(Self::diagnostic_code(error_code)),
10342            },
10343            SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
10344                status: "failed".to_owned(),
10345                started_at_ms,
10346                completed_at_ms,
10347                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
10348                counters: None,
10349                error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
10350            },
10351        });
10352        outcome
10353    }
10354
10355    /// Capture one request on the client owner. Mutations made after this
10356    /// call remain in the outbox for a later round; no network I/O occurs here.
10357    pub fn prepare_sync_round(
10358        &mut self,
10359        url_capable: bool,
10360    ) -> Result<crate::PreparedSyncRound, Box<SyncOutcome>> {
10361        if !self.transport_enabled {
10362            return Err(Box::new(SyncOutcome::Failed {
10363                error_code: "sync.offline".into(),
10364                message: "transport gate is closed".into(),
10365                details: None,
10366            }));
10367        }
10368        if self.active_round.is_some() {
10369            return Err(Box::new(SyncOutcome::Failed {
10370                error_code: "client.round_in_progress".into(),
10371                message: "a sync round is already in flight".into(),
10372                details: None,
10373            }));
10374        }
10375        self.storage_failure.borrow_mut().take();
10376        self.progress.start();
10377        self.round_retry_delay_ms = None;
10378        let started_at_ms = self.clock_now_ms();
10379        let prepared = (|| {
10380            if self.stopped {
10381                return Err(Box::new(SyncOutcome::Ok(SyncReport {
10382                    schema_floor: self.schema_floor.clone(),
10383                    ..SyncReport::default()
10384                })));
10385            }
10386            if self.security_preflight {
10387                return Err(Box::new(SyncOutcome::Failed {
10388                    error_code: SECURITY_PREFLIGHT_REQUIRED_CODE.into(),
10389                    message: "security preflight must complete before syncing".into(),
10390                    details: None,
10391                }));
10392            }
10393            self.set_sync_needed(false, false);
10394            let uploads = if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
10395                self.pending_blob_uploads().map_err(|e| {
10396                    Box::new(SyncOutcome::Failed {
10397                        error_code: e.code,
10398                        message: e.message,
10399                        details: e.details,
10400                    })
10401                })?
10402            } else {
10403                Vec::new()
10404            };
10405            self.drain_pending_evictions()
10406                .and_then(|_| self.drop_unencodable_outbox())
10407                .map_err(|message| {
10408                    Box::new(SyncOutcome::Failed {
10409                        error_code: "storage.failed".into(),
10410                        message,
10411                        details: None,
10412                    })
10413                })?;
10414            if self.realtime_state != RealtimeState::Connected
10415                && self.realtime_policy == RealtimePolicy::Required
10416            {
10417                let retry_delay_ms = self.schedule_background_retry();
10418                self.realtime_retry_delay_ms = Some(retry_delay_ms);
10419                return Err(Box::new(SyncOutcome::RealtimeUnavailable {
10420                    state: self.realtime_state,
10421                    reason_code: self.realtime_reason_code.clone(),
10422                    retry_delay_ms,
10423                }));
10424            }
10425            let (message, meta) = self.build_request(url_capable);
10426            let id = uuid::Uuid::new_v4();
10427            self.active_round = Some(id);
10428            Ok(crate::PreparedSyncRound {
10429                id,
10430                started_at_ms,
10431                message,
10432                meta,
10433                uploads: uploads.into(),
10434                realtime: self.realtime_state == RealtimeState::Connected,
10435                scopes: self
10436                    .subs
10437                    .iter()
10438                    .map(|s| (s.id.clone(), canonical_scope_json(&s.requested)))
10439                    .collect(),
10440                #[cfg(feature = "bench-internals")]
10441                benchmark_phases: self.benchmark_phases.clone(),
10442                progress: self.progress.clone(),
10443                fixed_now: self.now_ms,
10444            })
10445        })();
10446        prepared.map_err(|outcome| Box::new(self.finish_sync_round(started_at_ms, *outcome)))
10447    }
10448
10449    /// Discard the reply of an old authorization/subscription context. Its
10450    /// submitted commits stay durable and can be retried with the same ids.
10451    pub fn cancel_sync_round(&mut self) {
10452        self.active_round = None;
10453    }
10454
10455    /// Apply captured network results on the owner, preserving all normal
10456    /// version, schema, content-address and cursor checks. No network I/O.
10457    pub fn apply_sync_round(
10458        &mut self,
10459        completed: crate::CompletedSyncRound,
10460    ) -> crate::AppliedSyncRound {
10461        let prepared = completed.prepared;
10462        if self.active_round != Some(prepared.id) || self.security_preflight {
10463            return crate::AppliedSyncRound::Complete {
10464                outcome: SyncOutcome::Failed {
10465                    error_code: "client.round_cancelled".into(),
10466                    message: "sync round belongs to a released client context".into(),
10467                    details: None,
10468                },
10469                controls: Vec::new(),
10470                more: false,
10471                bootstrap_advanced: false,
10472            };
10473        }
10474        self.storage_failure.borrow_mut().take();
10475        let (response, mut downloads, transport_failed) = match completed.exchange {
10476            crate::round::ExchangeResult::Upload { id, result } => {
10477                let result = result.and_then(|()| {
10478                    self.conn
10479                        .execute(
10480                            "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
10481                            rusqlite::params![id],
10482                        )
10483                        .map(|_| ())
10484                        .map_err(|error| {
10485                            TransportError::new(
10486                                "client.failed",
10487                                Self::sqlite_failure(&self.storage_failure, error),
10488                            )
10489                        })
10490                });
10491                match result {
10492                    Ok(()) => return crate::AppliedSyncRound::Continue(prepared),
10493                    Err(TransportError {
10494                        code,
10495                        message,
10496                        details,
10497                    }) => {
10498                        self.active_round = None;
10499                        if Self::retryable_failure_code(&code) {
10500                            self.schedule_background_retry();
10501                        }
10502                        let outcome = self.finish_sync_round(
10503                            prepared.started_at_ms,
10504                            SyncOutcome::Failed {
10505                                error_code: code,
10506                                message,
10507                                details,
10508                            },
10509                        );
10510                        return crate::AppliedSyncRound::Complete {
10511                            outcome,
10512                            controls: Vec::new(),
10513                            more: false,
10514                            bootstrap_advanced: false,
10515                        };
10516                    }
10517                }
10518            }
10519            crate::round::ExchangeResult::Reply {
10520                response,
10521                downloads,
10522                transport_failed,
10523            } => (response, downloads, transport_failed),
10524        };
10525        self.active_round = None;
10526        let before = self
10527            .subs
10528            .iter()
10529            .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
10530            .collect::<Vec<_>>();
10531        let outcome = (|| {
10532            let response = match response {
10533                Ok(response) => response,
10534                Err(TransportError {
10535                    code,
10536                    message,
10537                    details,
10538                }) => {
10539                    if transport_failed {
10540                        if prepared.realtime {
10541                            self.set_realtime_state(RealtimeState::Lost, Some(&code));
10542                        }
10543                        self.record_lease_error(&code);
10544                    }
10545                    if Self::retryable_failure_code(&code) {
10546                        let delay = self.schedule_background_retry();
10547                        if prepared.realtime && transport_failed {
10548                            self.realtime_retry_delay_ms = Some(delay);
10549                        }
10550                    }
10551                    return SyncOutcome::Failed {
10552                        error_code: code,
10553                        message,
10554                        details,
10555                    };
10556                }
10557            };
10558            let mut outcome = self.process_response(&mut downloads, response, &prepared.meta);
10559            if let SyncOutcome::Ok(report) = &mut outcome {
10560                report.deferred_commits = prepared.meta.deferred_commits;
10561            }
10562            match &outcome {
10563                SyncOutcome::Ok(_) => self.reset_background_retry(),
10564                SyncOutcome::Failed { error_code, .. }
10565                    if Self::retryable_failure_code(error_code) =>
10566                {
10567                    self.schedule_background_retry();
10568                }
10569                _ => {}
10570            }
10571            if prepared.meta.deferred_commits > 0 {
10572                self.set_sync_needed(true, true);
10573            }
10574            self.previous_version_lifetime_check();
10575            outcome
10576        })();
10577        let more = self.transport_enabled
10578            && 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);
10579        let bootstrap_advanced = matches!(&outcome, SyncOutcome::Ok(report) if report.segment_rows_applied > 0 && !report.bootstrapping.is_empty())
10580            && before
10581                != self
10582                    .subs
10583                    .iter()
10584                    .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
10585                    .collect::<Vec<_>>();
10586        crate::AppliedSyncRound::Complete {
10587            outcome: self.finish_sync_round(prepared.started_at_ms, outcome),
10588            controls: if self.transport_enabled {
10589                downloads.controls
10590            } else {
10591                Vec::new()
10592            },
10593            more,
10594            bootstrap_advanced,
10595        }
10596    }
10597
10598    pub fn sync_until_idle(
10599        &mut self,
10600        transport: &mut dyn Transport,
10601        max_rounds: Option<u32>,
10602    ) -> SyncOutcome {
10603        let rounds = max_rounds.unwrap_or(20).max(1);
10604        let mut aggregate = SyncReport::default();
10605        let mut spent = 0;
10606        while spent < rounds {
10607            spent += 1;
10608            let before = self
10609                .subs
10610                .iter()
10611                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
10612                .collect::<Vec<_>>();
10613            let outcome = self.sync(transport);
10614            match outcome {
10615                SyncOutcome::Failed {
10616                    error_code,
10617                    message,
10618                    details,
10619                } => {
10620                    return SyncOutcome::Failed {
10621                        details,
10622                        error_code,
10623                        message,
10624                    };
10625                }
10626                SyncOutcome::RealtimeUnavailable { .. } => return outcome,
10627                SyncOutcome::Ok(report) => {
10628                    if max_rounds.is_none()
10629                        && report.segment_rows_applied > 0
10630                        && !report.bootstrapping.is_empty()
10631                        && before
10632                            != self
10633                                .subs
10634                                .iter()
10635                                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
10636                                .collect::<Vec<_>>()
10637                    {
10638                        spent = 0;
10639                    }
10640                    aggregate.merge(&report);
10641                    // §4.5: pull again whenever the response contained
10642                    // commits or segments; resets re-bootstrap; a pending
10643                    // resume token continues paging (§4.7); a raised
10644                    // sync-needed signal covers §6.1 splitBatch remainders
10645                    // (deferred outbox commits push on the next round).
10646                    let more = !report.bootstrapping.is_empty()
10647                        || report.commits_applied > 0
10648                        || report.segment_rows_applied > 0
10649                        || !report.resets.is_empty()
10650                        || self.sync_needed;
10651                    if !more {
10652                        return SyncOutcome::Ok(aggregate);
10653                    }
10654                }
10655            }
10656        }
10657        SyncOutcome::Failed {
10658            details: None,
10659            error_code: "sync.invalid_request".into(),
10660            message: "sync did not reach idle within the round budget".into(),
10661        }
10662    }
10663
10664    fn process_response(
10665        &mut self,
10666        transport: &mut dyn Transport,
10667        response: Message,
10668        meta: &RequestMeta,
10669    ) -> SyncOutcome {
10670        #[cfg(feature = "bench-internals")]
10671        let _phase = self.benchmark_phases.start(Phase::ResponseApply);
10672        let mut report = SyncReport {
10673            pushed: meta.pushed_ids.len() as u32,
10674            ..SyncReport::default()
10675        };
10676        let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
10677            HashMap::new();
10678        let pushed_ids = meta
10679            .pushed_ids
10680            .iter()
10681            .map(String::as_str)
10682            .collect::<HashSet<_>>();
10683        let mut last_final_push_result_id: Option<String> = None;
10684        for frame in &response.frames {
10685            match frame {
10686                Frame::PushResultDetails {
10687                    client_commit_id,
10688                    entries,
10689                } => {
10690                    let details = rejection_details_by_commit
10691                        .entry(client_commit_id.clone())
10692                        .or_default();
10693                    for entry in entries {
10694                        let parsed = match RejectionDetails::parse(&entry.details.0) {
10695                            Ok(value) => value,
10696                            Err(message) => {
10697                                return SyncOutcome::Failed {
10698                                    details: None,
10699                                    error_code: "sync.invalid_request".to_owned(),
10700                                    message,
10701                                };
10702                            }
10703                        };
10704                        details.insert(entry.op_index, parsed);
10705                    }
10706                }
10707                Frame::PushResult {
10708                    client_commit_id,
10709                    status,
10710                    results,
10711                    ..
10712                } if pushed_ids.contains(client_commit_id.as_str())
10713                    && Self::push_result_is_final(*status, results) =>
10714                {
10715                    last_final_push_result_id = Some(client_commit_id.clone());
10716                }
10717                _ => {}
10718            }
10719        }
10720        let mut frames = response.frames.into_iter().peekable();
10721        if response.wire_version < 2 {
10722            return SyncOutcome::Failed {
10723                details: None,
10724                error_code: "client.invalid_host_response".to_owned(),
10725                message: "server response does not carry wire version 2 log-epoch state".to_owned(),
10726            };
10727        }
10728        match frames.next() {
10729            Some(Frame::RespHeader {
10730                required_schema_version,
10731                latest_schema_version,
10732                log_epoch,
10733                reset_required,
10734            }) => {
10735                let Some(log_epoch) = log_epoch else {
10736                    return SyncOutcome::Failed {
10737                        details: None,
10738                        error_code: "client.invalid_host_response".to_owned(),
10739                        message: "response header omits logEpoch".to_owned(),
10740                    };
10741                };
10742                let Some(reset_required) = reset_required else {
10743                    return SyncOutcome::Failed {
10744                        details: None,
10745                        error_code: "client.invalid_host_response".to_owned(),
10746                        message: "response header omits resetRequired".to_owned(),
10747                    };
10748                };
10749                if let Some(required) = required_schema_version {
10750                    // §1.6 schema-floor response: nothing else is processed;
10751                    // stop syncing and surface the upgrade requirement.
10752                    let floor = SchemaFloor {
10753                        required_schema_version: Some(required),
10754                        latest_schema_version,
10755                    };
10756                    self.set_schema_floor(Some(floor.clone()));
10757                    report.schema_floor = Some(floor);
10758                    return SyncOutcome::Ok(report);
10759                }
10760                if reset_required {
10761                    if frames.next().is_some() {
10762                        return SyncOutcome::Failed {
10763                            details: None,
10764                            error_code: "client.invalid_host_response".to_owned(),
10765                            message: "log-epoch reset response contains body frames".to_owned(),
10766                        };
10767                    }
10768                    match self.run_log_epoch_reset(&log_epoch) {
10769                        Ok(resets) => {
10770                            report.resets = resets;
10771                            return SyncOutcome::Ok(report);
10772                        }
10773                        Err(message) => {
10774                            return SyncOutcome::Failed {
10775                                details: None,
10776                                error_code: "sync.local_corrupt".to_owned(),
10777                                message,
10778                            };
10779                        }
10780                    }
10781                }
10782                if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
10783                    return SyncOutcome::Failed {
10784                        details: None,
10785                        error_code: "client.invalid_host_response".to_owned(),
10786                        message: "server changed logEpoch without requiring a reset".to_owned(),
10787                    };
10788                }
10789            }
10790            _ => {
10791                return SyncOutcome::Failed {
10792                    details: None,
10793                    error_code: "sync.invalid_request".to_owned(),
10794                    message: "response does not start with RESP_HEADER".to_owned(),
10795                };
10796            }
10797        }
10798
10799        let mut failure: Option<(String, String)> = None;
10800        while let Some(frame) = frames.next() {
10801            match frame {
10802                frame @ Frame::PushResult { .. } => {
10803                    let successful = matches!(
10804                        &frame,
10805                        Frame::PushResult {
10806                            status: PushStatus::Applied | PushStatus::Cached,
10807                            ..
10808                        }
10809                    );
10810                    let mut results = vec![frame];
10811                    if successful {
10812                        while matches!(
10813                            frames.peek(),
10814                            Some(Frame::PushResult {
10815                                status: PushStatus::Applied | PushStatus::Cached,
10816                                ..
10817                            })
10818                        ) {
10819                            if let Some(next) = frames.next() {
10820                                results.push(next);
10821                            }
10822                        }
10823                    }
10824                    if self
10825                        .handle_push_results(
10826                            &results,
10827                            &pushed_ids,
10828                            &rejection_details_by_commit,
10829                            &mut report,
10830                            last_final_push_result_id.as_deref(),
10831                        )
10832                        .is_err()
10833                    {
10834                        failure = Some((
10835                            "client.outcome_persistence_failed".to_owned(),
10836                            "local commit outcome could not be persisted".to_owned(),
10837                        ));
10838                        break;
10839                    }
10840                }
10841                Frame::PushResultDetails { .. } => {}
10842                Frame::SubStart {
10843                    id,
10844                    status,
10845                    reason_code,
10846                    effective_scopes,
10847                    bootstrap: _,
10848                } => {
10849                    let mut body = Vec::new();
10850                    let mut sub_end: Option<(i64, Option<String>)> = None;
10851                    for inner in frames.by_ref() {
10852                        match inner {
10853                            Frame::SubEnd {
10854                                next_cursor,
10855                                bootstrap_state,
10856                            } => {
10857                                sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
10858                                break;
10859                            }
10860                            Frame::Unknown { .. } => {}
10861                            other => body.push(other),
10862                        }
10863                    }
10864                    if let Err(SectionError::Abort(code, message)) = self.process_section(
10865                        transport,
10866                        &id,
10867                        status,
10868                        &reason_code,
10869                        effective_scopes,
10870                        body,
10871                        sub_end,
10872                        meta,
10873                        &mut report,
10874                    ) {
10875                        failure = Some((code, message));
10876                        break;
10877                    }
10878                }
10879                Frame::Lease {
10880                    lease_id,
10881                    expires_at_ms,
10882                } => {
10883                    // §7.3.5: persist the opaque lease; a fresh lease clears
10884                    // any prior lease error (the outage/revocation is over).
10885                    self.set_lease_state(Some(LeaseState {
10886                        lease_id: Some(lease_id),
10887                        expires_at_ms: Some(expires_at_ms),
10888                        error_code: None,
10889                    }));
10890                }
10891                Frame::Error { code, message, .. } => {
10892                    // §1.6: the whole request failed; already-completed
10893                    // subscriptions keep their applied data and cursors.
10894                    failure = Some((code, message));
10895                    break;
10896                }
10897                Frame::Unknown { .. } => {}
10898                _ => {
10899                    failure = Some((
10900                        "sync.invalid_request".to_owned(),
10901                        "unexpected frame in response".to_owned(),
10902                    ));
10903                    break;
10904                }
10905            }
10906        }
10907
10908        // §7.1: reconcile the visible overlay once at the response boundary
10909        // after all outbox acknowledgements and server data applied so a
10910        // batch of PUSH_RESULT frames cannot trigger repeated full-table
10911        // rebuilds. This also covers a round that aborted mid-way.
10912        if self.overlay_dirty.get() {
10913            self.rebuild_overlay();
10914        }
10915        if let Some((error_code, message)) = failure {
10916            return SyncOutcome::Failed {
10917                details: None,
10918                error_code,
10919                message,
10920            };
10921        }
10922        // §4.8 E1: the push half may have drained commits that pinned rows of
10923        // a shrunk window unit — retry any deferred evictions now.
10924        if let Err(message) = self.drain_pending_evictions() {
10925            return SyncOutcome::Failed {
10926                details: None,
10927                error_code: "storage.failed".into(),
10928                message,
10929            };
10930        }
10931        self.ack_after_pull(transport);
10932        // §7.4.5: the reset is over once the first post-reset pull round
10933        // leaves no subscription mid-bootstrap — the tables are rebuilt.
10934        if self.upgrading && report.bootstrapping.is_empty() {
10935            self.set_upgrading(false);
10936        }
10937        SyncOutcome::Ok(report)
10938    }
10939
10940    // -- push results (§6.3, §7.2) ------------------------------------------------
10941
10942    fn handle_push_results(
10943        &mut self,
10944        frames: &[Frame],
10945        pushed_ids: &HashSet<&str>,
10946        rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
10947        report: &mut SyncReport,
10948        last_final_push_result_id: Option<&str>,
10949    ) -> Result<(), String> {
10950        self.begin_observation("syncular_push_results")?;
10951        let previous_failed = self.failed_commits.clone();
10952        let previous_sync_needed = self.sync_needed;
10953        let conflict_count = self.conflicts.len();
10954        let rejection_count = self.rejections.len();
10955        let applied_count = report.applied.len();
10956        let rejected_count = report.rejected.len();
10957        let retryable_count = report.retryable.len();
10958        let was_dirty = self.overlay_dirty.snapshot();
10959        let mut removed = Vec::new();
10960        let mut batch = ChangeAccumulator::default();
10961        let persisted = (|| {
10962            for frame in frames {
10963                let Frame::PushResult {
10964                    client_commit_id,
10965                    status,
10966                    results,
10967                    commit_seq,
10968                } = frame
10969                else {
10970                    return Err("unexpected acknowledgement frame".to_owned());
10971                };
10972                if !pushed_ids.contains(client_commit_id.as_str()) {
10973                    continue;
10974                }
10975                let Some(index) = self
10976                    .outbox
10977                    .iter()
10978                    .position(|commit| commit.client_commit_id == *client_commit_id)
10979                else {
10980                    continue;
10981                };
10982                let status = *status;
10983                let rejection_details = rejection_details_by_commit.get(client_commit_id);
10984                let operations = self.outbox[index].ops.clone();
10985                match status {
10986                    PushStatus::Applied | PushStatus::Cached => {
10987                        // §7.2: a lost ack replays as `cached` — proceed as if the
10988                        // ack had arrived.
10989                        let sequence =
10990                            commit_seq.ok_or_else(|| "ACK lacks a commit sequence".to_owned())?;
10991                        let prior = self.acknowledged_operations(None)?;
10992                        for (op_index, operation) in operations.iter().enumerate() {
10993                            let delivered: bool = self.conn.query_row("SELECT EXISTS(SELECT 1 FROM _syncular_row_deliveries WHERE tbl=?1 AND id=?2 AND commit_seq>=?3)", rusqlite::params![operation.table, operation.row_id, sequence], |row| row.get(0))
10994                                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10995                            if delivered {
10996                                continue;
10997                            }
10998                            let table = self
10999                                .schema
11000                                .table(&operation.table)
11001                                .ok_or_else(|| "sync.unknown_table".to_owned())?;
11002                            let mut intended: Map<String, Value> = self
11003                                .stored_row(table, &operation.row_id, true)
11004                                .map(|(row, _)| {
11005                                    table
11006                                        .columns
11007                                        .iter()
11008                                        .zip(&row)
11009                                        .map(|(column, value)| {
11010                                            (column.name.clone(), column_value_to_json(value))
11011                                        })
11012                                        .collect()
11013                                })
11014                                .unwrap_or_default();
11015                            let mut scope_image = intended.clone();
11016                            for op in prior
11017                                .iter()
11018                                .chain(
11019                                    self.failed_commits
11020                                        .iter()
11021                                        .flat_map(|(commit, _)| &commit.ops),
11022                                )
11023                                .chain(
11024                                    self.outbox
11025                                        .iter()
11026                                        .take(index)
11027                                        .flat_map(|commit| &commit.ops),
11028                                )
11029                                .chain(operations.iter().take(op_index + 1))
11030                            {
11031                                if op.table != operation.table || op.row_id != operation.row_id {
11032                                    continue;
11033                                }
11034                                if let Some(values) = &op.values {
11035                                    intended.extend(values.clone());
11036                                } else {
11037                                    scope_image = intended.clone();
11038                                    intended.clear();
11039                                }
11040                            }
11041                            self.conn.execute("INSERT INTO _syncular_acked_rows(commit_id,idx,tbl,id,commit_seq,op_json,intent_json) VALUES (?1,?2,?3,?4,?5,?6,?7)",
11042                                rusqlite::params![client_commit_id, op_index as i64, operation.table, operation.row_id, sequence,
11043                                    serde_json::to_string(&CommitOperation::from(operation)).map_err(|_| "sync.local_corrupt".to_owned())?,
11044                                    serde_json::to_string(if operation.upsert { &intended } else { &scope_image }).map_err(|_| "sync.local_corrupt".to_owned())?])
11045                                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11046                        }
11047                        let journal_results = results
11048                            .iter()
11049                            .map(|result| {
11050                                let op_index = match result {
11051                                    OpResult::Applied { op_index }
11052                                    | OpResult::Conflict { op_index, .. }
11053                                    | OpResult::Error { op_index, .. } => *op_index,
11054                                };
11055                                CommitOperationOutcome::Applied { op_index }
11056                            })
11057                            .collect::<Vec<_>>();
11058                        let outcome_status = if status == PushStatus::Applied {
11059                            CommitOutcomeStatus::Applied
11060                        } else {
11061                            CommitOutcomeStatus::Cached
11062                        };
11063                        let persisted = self
11064                            .persist_commit_outcome(
11065                                client_commit_id,
11066                                outcome_status,
11067                                &journal_results,
11068                                None,
11069                            )
11070                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
11071                        persisted?;
11072                        for operation in &operations {
11073                            self.overlay_dirty.table(&operation.table);
11074                        }
11075                        self.sync_needed = true;
11076                        batch.status = true;
11077                        batch.outcomes = true;
11078                    }
11079                    PushStatus::Rejected => {
11080                        if results.iter().any(|result| {
11081                            matches!(
11082                                result,
11083                                OpResult::Error {
11084                                    code,
11085                                    retryable: true,
11086                                    ..
11087                                } if code == "sync.idempotency_cache_miss"
11088                            )
11089                        }) {
11090                            // §6.3/§7.2: a serving failure, not an outcome — keep the
11091                            // exact commit queued for an identical retry.
11092                            report.retryable.push(client_commit_id.to_owned());
11093                            continue;
11094                        }
11095
11096                        let mut journal_results = Vec::with_capacity(results.len());
11097                        let mut conflicts = Vec::new();
11098                        let mut rejections = Vec::new();
11099                        for result in results {
11100                            match result {
11101                                OpResult::Applied { op_index } => {
11102                                    journal_results.push(CommitOperationOutcome::Applied {
11103                                        op_index: *op_index,
11104                                    });
11105                                }
11106                                OpResult::Conflict {
11107                                    op_index,
11108                                    code,
11109                                    message,
11110                                    server_version,
11111                                    server_row,
11112                                    conflict_columns: conflict_columns_bitmap,
11113                                } => {
11114                                    let operation = operations
11115                                        .get(*op_index as usize)
11116                                        .map(CommitOperation::from);
11117                                    let (table, row_id) = operation
11118                                        .as_ref()
11119                                        .map(|op| (op.table.clone(), op.row_id.clone()))
11120                                        .unwrap_or_default();
11121                                    let server_row_json = self
11122                                        .schema
11123                                        .table(&table)
11124                                        .and_then(|t| {
11125                                            decode_row_bytes(t, server_row, &self.encryption)
11126                                                .ok()
11127                                                .map(|row| (t, row))
11128                                        })
11129                                        .map(|(t, row)| {
11130                                            let mut map = Map::new();
11131                                            for (i, column) in t.columns.iter().enumerate() {
11132                                                map.insert(
11133                                                    column.name.clone(),
11134                                                    column_value_to_json(
11135                                                        row.get(i).unwrap_or(&None),
11136                                                    ),
11137                                                );
11138                                            }
11139                                            map
11140                                        })
11141                                        .unwrap_or_default();
11142                                    // §6.3: decode the presence-layout bitmap
11143                                    // into the marked column names.
11144                                    let conflict_columns = self
11145                                        .schema
11146                                        .table(&table)
11147                                        .map(|t| {
11148                                            t.columns
11149                                                .iter()
11150                                                .enumerate()
11151                                                .filter(|(index, _)| {
11152                                                    conflict_columns_bitmap
11153                                                        .get(index / 8)
11154                                                        .is_some_and(|byte| {
11155                                                            byte & (1 << (index % 8)) != 0
11156                                                        })
11157                                                })
11158                                                .map(|(_, column)| column.name.clone())
11159                                                .collect::<Vec<_>>()
11160                                        })
11161                                        .unwrap_or_default();
11162                                    let conflict = ConflictRecord {
11163                                        client_commit_id: client_commit_id.to_owned(),
11164                                        op_index: *op_index,
11165                                        table,
11166                                        row_id,
11167                                        code: code.clone(),
11168                                        message: message.clone(),
11169                                        server_version: *server_version,
11170                                        server_row: server_row_json,
11171                                        conflict_columns,
11172                                        operation,
11173                                    };
11174                                    journal_results.push(CommitOperationOutcome::Conflict {
11175                                        conflict: conflict.clone(),
11176                                    });
11177                                    conflicts.push(conflict);
11178                                }
11179                                OpResult::Error {
11180                                    op_index,
11181                                    code,
11182                                    message,
11183                                    retryable,
11184                                } => {
11185                                    let rejection = RejectionRecord {
11186                                        client_commit_id: client_commit_id.to_owned(),
11187                                        op_index: *op_index,
11188                                        code: code.clone(),
11189                                        message: message.clone(),
11190                                        retryable: *retryable,
11191                                        details: rejection_details
11192                                            .and_then(|details| details.get(op_index))
11193                                            .cloned(),
11194                                        operation: operations
11195                                            .get(*op_index as usize)
11196                                            .map(CommitOperation::from),
11197                                    };
11198                                    journal_results.push(CommitOperationOutcome::Error {
11199                                        rejection: rejection.clone(),
11200                                    });
11201                                    rejections.push(rejection);
11202                                }
11203                            }
11204                        }
11205                        if self.retain_failed_commits {
11206                            self.retain_failed_commit(client_commit_id, &operations)?;
11207                        }
11208                        let outcome_status = if conflicts.is_empty() {
11209                            CommitOutcomeStatus::Rejected
11210                        } else {
11211                            CommitOutcomeStatus::Conflict
11212                        };
11213                        let persisted = self
11214                            .persist_commit_outcome(
11215                                client_commit_id,
11216                                outcome_status,
11217                                &journal_results,
11218                                Some(&operations),
11219                            )
11220                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
11221                        persisted?;
11222                        batch.conflicts = !conflicts.is_empty();
11223                        batch.rejections = !rejections.is_empty();
11224                        batch.status = true;
11225                        batch.outcomes = true;
11226                        self.conflicts.extend(conflicts);
11227                        self.rejections.extend(rejections);
11228                        for operation in &operations {
11229                            self.overlay_dirty.table(&operation.table);
11230                        }
11231                    }
11232                }
11233                removed.push((index, self.outbox.remove(index)));
11234                if batch.status {
11235                    for operation in &operations {
11236                        if !self.record_row_scopes(
11237                            &mut batch,
11238                            &operation.table,
11239                            &operation.row_id,
11240                            false,
11241                        ) {
11242                            batch.table(&operation.table);
11243                        }
11244                    }
11245                }
11246                match status {
11247                    PushStatus::Applied | PushStatus::Cached => {
11248                        report.applied.push(client_commit_id.to_owned())
11249                    }
11250                    PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
11251                }
11252            }
11253            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)) {
11254                self.prune_commit_outcomes()?;
11255            }
11256            self.finish_observation("syncular_push_results", batch)
11257        })();
11258        if let Err(error) = persisted {
11259            self.rollback_observation("syncular_push_results");
11260            self.failed_commits = previous_failed;
11261            self.sync_needed = previous_sync_needed;
11262            for (index, commit) in removed.into_iter().rev() {
11263                self.outbox.insert(index, commit);
11264            }
11265            self.conflicts.truncate(conflict_count);
11266            self.rejections.truncate(rejection_count);
11267            self.overlay_dirty.restore(was_dirty.clone());
11268            report.applied.truncate(applied_count);
11269            report.rejected.truncate(rejected_count);
11270            report.retryable.truncate(retryable_count);
11271            return Err(error);
11272        }
11273        if !previous_sync_needed && self.sync_needed {
11274            self.sync_intent_queue.push_back(SyncIntent::Interactive);
11275        }
11276        report.conflicts += (self.conflicts.len() - conflict_count) as u32;
11277        Ok(())
11278    }
11279
11280    fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
11281        status != PushStatus::Rejected
11282            || !results.iter().any(|result| {
11283                matches!(
11284                    result,
11285                    OpResult::Error {
11286                        code,
11287                        retryable: true,
11288                        ..
11289                    } if code == "sync.idempotency_cache_miss"
11290                )
11291            })
11292    }
11293
11294    // -- subscription sections ------------------------------------------------------
11295
11296    #[allow(clippy::too_many_arguments)]
11297    fn process_section(
11298        &mut self,
11299        transport: &mut dyn Transport,
11300        id: &str,
11301        status: SubStatus,
11302        reason_code: &str,
11303        effective_scopes: Vec<(String, Vec<String>)>,
11304        body: Vec<Frame>,
11305        sub_end: Option<(i64, Option<String>)>,
11306        meta: &RequestMeta,
11307        report: &mut SyncReport,
11308    ) -> Result<(), SectionError> {
11309        let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
11310            return Ok(()); // unknown echo: ignore
11311        };
11312        if status != SubStatus::Active && sub_end.is_none() {
11313            return Err(SectionError::Abort(
11314                "sync.invalid_request".into(),
11315                "subscription section without SUB_END".into(),
11316            ));
11317        }
11318        match status {
11319            SubStatus::Revoked => {
11320                self.begin_observation("syncular_revocation")
11321                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
11322                let previous = self.subs[sub_index].clone();
11323                let was_dirty = self.overlay_dirty.snapshot();
11324                let previous_outbox = self.outbox.clone();
11325                let previous_failed = self.failed_commits.clone();
11326                let previous_rejections = self.rejections.clone();
11327                let previous_conflicts = self.conflicts.clone();
11328                let revoked_count = report.revoked.len();
11329                let failed_count = report.failed.len();
11330                let result =
11331                    (|| {
11332                        let mut batch = ChangeAccumulator::default();
11333                        let registered = self.window_unit_by_sub_id(id);
11334                        // §3.3: stop pulling, purge exactly the last effective grant.
11335                        let (table, effective) = {
11336                            let sub = &self.subs[sub_index];
11337                            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
11338                        };
11339                        let scoped_table = self.schema.table(&table).cloned().ok_or_else(|| {
11340                            SectionError::Abort(
11341                                "sync.unknown_table".to_owned(),
11342                                "revoked scope references an unknown table".to_owned(),
11343                            )
11344                        })?;
11345                        let dropped = self
11346                            .drop_failed_commits(|name, values| {
11347                                name == table
11348                                    && !effective.is_empty()
11349                                    && effective.iter().all(|(variable, allowed)| {
11350                                        scoped_table
11351                                            .scope_column(variable)
11352                                            .and_then(|column| values.get(column))
11353                                            .and_then(Value::as_str)
11354                                            .is_some_and(|value| {
11355                                                allowed.iter().any(|allowed| allowed == value)
11356                                            })
11357                                    })
11358                            })
11359                            .map_err(|message| {
11360                                SectionError::Abort("storage.failed".to_owned(), message)
11361                            })?;
11362                        for commit in dropped {
11363                            batch.conflicts = true;
11364                            batch.rejections = true;
11365                            batch.outcomes = true;
11366                            for operation in commit.ops {
11367                                batch.table(&operation.table);
11368                            }
11369                        }
11370                        let purged = self.purge_scope_rows(&table, &effective);
11371                        match purged {
11372                            Ok(()) => {
11373                                self.record_scope_map(&mut batch, &table, &effective);
11374                                let sub = &mut self.subs[sub_index];
11375                                sub.state = SubState::Revoked;
11376                                sub.reason_code = Some(if reason_code.is_empty() {
11377                                    "sync.scope_revoked".to_owned()
11378                                } else {
11379                                    reason_code.to_owned()
11380                                });
11381                                report.revoked.push(id.to_owned());
11382                                let doomed_effective = effective;
11383                                let sub_table = table;
11384                                self.persist_sub(&self.subs[sub_index].clone()).map_err(
11385                                    |message| SectionError::Abort("storage.failed".into(), message),
11386                                )?;
11387                                let dropped = self
11388                                    .drop_doomed_outbox(&sub_table, &doomed_effective)
11389                                    .map_err(|message| {
11390                                        SectionError::Abort("storage.failed".to_owned(), message)
11391                                    })?;
11392                                if dropped {
11393                                    batch.status = true;
11394                                    batch.rejections = true;
11395                                    batch.outcomes = true;
11396                                }
11397                                // §5.9.7 B2: revocation deletes now-unauthorized blob
11398                                // bodies (evicted ≠ revoked).
11399                                self.delete_unreferenced_cached_blobs();
11400                            }
11401                            Err(()) => {
11402                                // §3.3 fail closed: no local mapping — never clear by
11403                                // approximation; fatal configuration error.
11404                                let sub = &mut self.subs[sub_index];
11405                                sub.state = SubState::Failed;
11406                                sub.reason_code = Some("sync.scope_revoked".to_owned());
11407                                report.failed.push(id.to_owned());
11408                                self.persist_sub(&self.subs[sub_index].clone()).map_err(
11409                                    |message| SectionError::Abort("storage.failed".into(), message),
11410                                )?;
11411                            }
11412                        }
11413                        if let Some((base_key, unit)) = registered {
11414                            batch.window(&base_key, &self.subs[sub_index].table, &unit);
11415                        }
11416                        self.rebuild_overlay_if_dirty();
11417                        self.finish_observation("syncular_revocation", batch)
11418                            .map_err(|message| {
11419                                SectionError::Abort("storage.failed".to_owned(), message)
11420                            })?;
11421                        Ok(())
11422                    })();
11423                if result.is_err() {
11424                    self.rollback_observation("syncular_revocation");
11425                    self.subs[sub_index] = previous;
11426                    self.overlay_dirty.restore(was_dirty.clone());
11427                    self.outbox = previous_outbox;
11428                    self.failed_commits = previous_failed;
11429                    self.rejections = previous_rejections;
11430                    self.conflicts = previous_conflicts;
11431                    report.revoked.truncate(revoked_count);
11432                    report.failed.truncate(failed_count);
11433                }
11434                result
11435            }
11436            SubStatus::Reset => {
11437                self.begin_observation("syncular_reset")
11438                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
11439                let previous = self.subs[sub_index].clone();
11440                let was_dirty = self.overlay_dirty.snapshot();
11441                let reset_count = report.resets.len();
11442                let result = (|| {
11443                    let mut batch = ChangeAccumulator::default();
11444                    let registered = self.window_unit_by_sub_id(id);
11445                    // §4.6: discard cursor + bootstrap state, keep local rows —
11446                    // reset is a staleness signal, not a purge signal.
11447                    let sub = &mut self.subs[sub_index];
11448                    sub.cursor = -1;
11449                    sub.bootstrap_state = None;
11450                    report.resets.push(id.to_owned());
11451                    self.persist_sub(&self.subs[sub_index].clone())
11452                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11453                    if let Some((base_key, unit)) = registered {
11454                        batch.window(&base_key, &self.subs[sub_index].table, &unit);
11455                    }
11456                    self.finish_observation("syncular_reset", batch)
11457                        .map_err(|message| {
11458                            SectionError::Abort("storage.failed".to_owned(), message)
11459                        })?;
11460                    Ok(())
11461                })();
11462                if result.is_err() {
11463                    self.rollback_observation("syncular_reset");
11464                    self.subs[sub_index] = previous;
11465                    self.overlay_dirty.restore(was_dirty.clone());
11466                    report.resets.truncate(reset_count);
11467                }
11468                result
11469            }
11470            SubStatus::Active => {
11471                let fresh = meta
11472                    .fresh
11473                    .iter()
11474                    .find(|(fid, _)| fid == id)
11475                    .map(|(_, fresh)| *fresh)
11476                    .unwrap_or(false);
11477                let previous = self.subs[sub_index].clone();
11478                self.subs[sub_index].effective = Some(effective_scopes);
11479                let outcome = self
11480                    .apply_section_body(transport, sub_index, body, fresh, meta, report)
11481                    .and_then(|()| {
11482                        let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
11483                            SectionError::Abort(
11484                                "sync.invalid_request".into(),
11485                                "subscription section without SUB_END".into(),
11486                            )
11487                        })?;
11488                        self.apply_sub_end(sub_index, cursor, bootstrap_state)
11489                            .map_err(|message| {
11490                                SectionError::Abort("storage.failed".into(), message)
11491                            })
11492                    });
11493                match outcome {
11494                    Ok(()) => {
11495                        if self.subs[sub_index].bootstrap_state.is_some() {
11496                            report.bootstrapping.push(id.to_owned());
11497                        }
11498                        Ok(())
11499                    }
11500                    Err(SectionError::FailClosed) => {
11501                        self.subs[sub_index] = previous.clone();
11502                        self.begin_observation("syncular_section_failure")
11503                            .map_err(|message| {
11504                                SectionError::Abort("storage.failed".into(), message)
11505                            })?;
11506                        let persisted = (|| {
11507                            let mut batch = ChangeAccumulator::default();
11508                            if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
11509                                batch.window(&base_key, &self.subs[sub_index].table, &unit);
11510                            }
11511                            self.subs[sub_index].state = SubState::Failed;
11512                            self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
11513                            self.persist_sub(&self.subs[sub_index])?;
11514                            self.finish_observation("syncular_section_failure", batch)
11515                        })();
11516                        if let Err(message) = persisted {
11517                            self.rollback_observation("syncular_section_failure");
11518                            self.subs[sub_index] = previous;
11519                            return Err(SectionError::Abort("storage.failed".into(), message));
11520                        }
11521                        report.failed.push(id.to_owned());
11522                        Ok(())
11523                    }
11524                    Err(error) => {
11525                        // Frame/block transactions already completed. Preserve their
11526                        // rows and revisions, but leave this subscription cursor unchanged.
11527                        self.subs[sub_index] = previous;
11528                        Err(error)
11529                    }
11530                }
11531            }
11532        }
11533    }
11534
11535    fn apply_sub_end(
11536        &mut self,
11537        sub_index: usize,
11538        cursor: i64,
11539        bootstrap_state: Option<String>,
11540    ) -> Result<(), String> {
11541        #[cfg(feature = "bench-internals")]
11542        let _phase = self.benchmark_phases.start(Phase::CursorPersist);
11543        let previous = self.subs[sub_index].clone();
11544        self.begin_observation("syncular_sub_end")?;
11545        let persisted = (|| {
11546            let mut batch = ChangeAccumulator::default();
11547            let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
11548                && cursor >= 0
11549                && bootstrap_state.is_none();
11550            if completed {
11551                let table = self
11552                    .schema
11553                    .table(&previous.table)
11554                    .ok_or_else(|| "sync.unknown_table".to_owned())?;
11555                let effective = previous.effective.as_deref().unwrap_or(&[]);
11556                let mut statement = self.conn.prepare("SELECT id,intent_json FROM _syncular_acked_rows WHERE tbl=?1 AND commit_seq<=?2")
11557                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11558                let rows = statement
11559                    .query_map(rusqlite::params![previous.table, cursor], |row| {
11560                        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
11561                    })
11562                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11563                let mut covered = HashSet::new();
11564                for row in rows {
11565                    let (id, intent) =
11566                        row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11567                    let values: Map<String, Value> = serde_json::from_str(&intent)
11568                        .map_err(|_| "sync.local_corrupt".to_owned())?;
11569                    if !effective.is_empty()
11570                        && effective.iter().all(|(variable, allowed)| {
11571                            table
11572                                .scope_column(variable)
11573                                .and_then(|column| values.get(column))
11574                                .is_some_and(|value| {
11575                                    allowed.contains(
11576                                        &value
11577                                            .as_str()
11578                                            .map_or_else(|| value.to_string(), str::to_owned),
11579                                    )
11580                                })
11581                        })
11582                    {
11583                        covered.insert(id);
11584                    }
11585                }
11586                drop(statement);
11587                for id in covered {
11588                    self.conn.execute("DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3", rusqlite::params![previous.table, id, cursor])
11589                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11590                    self.overlay_dirty.table(&previous.table);
11591                    batch.table(&previous.table);
11592                }
11593                self.rebuild_overlay_if_dirty();
11594                if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
11595                    batch.window(&base_key, &previous.table, &unit);
11596                }
11597            }
11598            let sub = &mut self.subs[sub_index];
11599            sub.cursor = cursor;
11600            sub.bootstrap_state = bootstrap_state;
11601            sub.synced_once = true;
11602            self.persist_sub(&self.subs[sub_index])?;
11603            self.finish_observation("syncular_sub_end", batch)
11604        })();
11605        if persisted.is_err() {
11606            self.rollback_observation("syncular_sub_end");
11607            self.subs[sub_index] = previous;
11608        }
11609        persisted
11610    }
11611
11612    fn apply_section_body(
11613        &mut self,
11614        transport: &mut dyn Transport,
11615        sub_index: usize,
11616        body: Vec<Frame>,
11617        fresh: bool,
11618        meta: &RequestMeta,
11619        report: &mut SyncReport,
11620    ) -> Result<(), SectionError> {
11621        let mut saw_segment = false;
11622        for frame in body {
11623            match frame {
11624                Frame::Commit {
11625                    tables,
11626                    changes,
11627                    commit_seq,
11628                    ..
11629                } => {
11630                    self.apply_commit_frame(&tables, &changes, Some(commit_seq))
11631                        .map_err(|(c, m)| SectionError::Abort(c, m))?;
11632                    report.commits_applied += 1;
11633                }
11634                Frame::SegmentInline { payload } => {
11635                    let segment = decode_rows_segment(&payload)
11636                        .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
11637                    self.progress.update(|p| {
11638                        p.phase = ProgressPhase::Import;
11639                        p.subscription_id = Some(self.subs[sub_index].id.clone());
11640                        p.table = Some(self.subs[sub_index].table.clone());
11641                        p.segment_id = None;
11642                        p.bytes_received = payload.len() as u64;
11643                        p.bytes_total = Some(payload.len() as u64);
11644                        p.rows_processed = 0;
11645                        p.rows_total =
11646                            Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
11647                    });
11648                    let first = !saw_segment;
11649                    saw_segment = true;
11650                    let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
11651                    report.segment_rows_applied += applied;
11652                }
11653                Frame::SegmentRef {
11654                    segment_id,
11655                    byte_length,
11656                    media_type,
11657                    table,
11658                    row_count,
11659                    as_of_commit_seq,
11660                    scope_digest,
11661                    row_cursor,
11662                    next_row_cursor,
11663                    url,
11664                    url_expires_at_ms,
11665                    ..
11666                } => {
11667                    // §4.2: reject a descriptor whose mediaType was not
11668                    // advertised — never skip or guess.
11669                    let advertised = match media_type {
11670                        MediaType::Rows => {
11671                            meta.accept & ACCEPT_EXTERNAL_ROWS != 0
11672                                || meta.accept & ACCEPT_INLINE_ROWS != 0
11673                        }
11674                        MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
11675                    };
11676                    if !advertised {
11677                        return Err(SectionError::Abort(
11678                            "sync.invalid_request".to_owned(),
11679                            format!(
11680                                "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
11681                                media_type.name()
11682                            ),
11683                        ));
11684                    }
11685                    self.progress.update(|p| {
11686                        p.phase = ProgressPhase::Download;
11687                        p.subscription_id = Some(self.subs[sub_index].id.clone());
11688                        p.table = Some(table.clone());
11689                        p.segment_id = Some(segment_id.clone());
11690                        p.bytes_received = 0;
11691                        p.bytes_total = u64::try_from(byte_length).ok();
11692                        p.rows_processed = 0;
11693                        p.rows_total = u64::try_from(row_count).ok();
11694                    });
11695                    let observer = self.progress.clone();
11696                    let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
11697                    let bytes = if let Some(url) = url {
11698                        // §5.4: a url-carrying descriptor MUST be fetched
11699                        // from that URL; failure invalidates the whole
11700                        // descriptor (no fall-through to §5.5 — re-pull
11701                        // recovers, §1.4 rule 5).
11702                        if meta.accept & ACCEPT_SIGNED_URLS == 0 {
11703                            return Err(SectionError::Abort(
11704                                "sync.invalid_request".to_owned(),
11705                                "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
11706                                    .to_owned(),
11707                            ));
11708                        }
11709                        // §5.4: MUST NOT start a fetch at/past expiry.
11710                        if url_expires_at_ms.is_some_and(|exp| {
11711                            exp <= transport
11712                                .segment_fetch_started_at(&url)
11713                                .unwrap_or_else(|| self.clock_now_ms())
11714                        }) {
11715                            return Err(SectionError::Abort(
11716                                "sync.segment_expired".to_owned(),
11717                                format!(
11718                                    "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
11719                                ),
11720                            ));
11721                        }
11722                        transport
11723                            .fetch_url(&url, &mut on_progress)
11724                            .map_err(|e| SectionError::Abort(e.code, e.message))?
11725                    } else {
11726                        let requested_scopes_json =
11727                            canonical_scope_json(&self.subs[sub_index].requested);
11728                        transport
11729                            .download_segment(
11730                                &SegmentRequest {
11731                                    segment_id: segment_id.clone(),
11732                                    table,
11733                                    requested_scopes_json,
11734                                },
11735                                &mut on_progress,
11736                            )
11737                            .map_err(|e| SectionError::Abort(e.code, e.message))?
11738                    };
11739                    // §5.1: verify the content address before applying.
11740                    on_progress(bytes.len() as u64);
11741                    let digest = Sha256::digest(&bytes);
11742                    let expected = segment_id
11743                        .strip_prefix("sha256:")
11744                        .unwrap_or(segment_id.as_str());
11745                    if bytes_to_hex(&digest) != expected {
11746                        // §5.1: the client discards the segment and re-pulls.
11747                        self.schedule_background_retry();
11748                        return Err(SectionError::Abort(
11749                            "sync.invalid_request".to_owned(),
11750                            "segment bytes do not match the content address (§5.1)".to_owned(),
11751                        ));
11752                    }
11753                    self.progress.update(|p| p.phase = ProgressPhase::Import);
11754                    if media_type == MediaType::Sqlite {
11755                        // §5.3: images are whole-table — a paged sqlite
11756                        // descriptor is invalid.
11757                        if row_cursor.is_some() || next_row_cursor.is_some() {
11758                            return Err(SectionError::Abort(
11759                                "sync.invalid_request".to_owned(),
11760                                "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
11761                                    .to_owned(),
11762                            ));
11763                        }
11764                        let first = !saw_segment;
11765                        saw_segment = true;
11766                        let applied = self.apply_sqlite_segment(
11767                            sub_index,
11768                            &bytes,
11769                            fresh && first,
11770                            row_count,
11771                            as_of_commit_seq,
11772                            &scope_digest,
11773                        )?;
11774                        report.segment_rows_applied += applied;
11775                    } else {
11776                        let segment = decode_rows_segment(&bytes).map_err(|e| {
11777                            SectionError::Abort(e.code.as_str().to_owned(), e.detail)
11778                        })?;
11779                        let first = row_cursor.is_none();
11780                        saw_segment = true;
11781                        let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
11782                        report.segment_rows_applied += applied;
11783                    }
11784                }
11785                Frame::Unknown { .. } => {}
11786                Frame::Error { code, message, .. } => {
11787                    return Err(SectionError::Abort(code, message))
11788                }
11789                _ => {
11790                    return Err(SectionError::Abort(
11791                        "sync.invalid_request".to_owned(),
11792                        "unexpected frame inside a subscription section".to_owned(),
11793                    ));
11794                }
11795            }
11796        }
11797        Ok(())
11798    }
11799
11800    // Both pull and realtime frames use the same durable observation boundary.
11801    fn apply_commit_frame(
11802        &mut self,
11803        tables: &[String],
11804        changes: &[ssp2::model::Change],
11805        commit_seq: Option<i64>,
11806    ) -> Result<(), (String, String)> {
11807        #[cfg(feature = "bench-internals")]
11808        let _phase = self.benchmark_phases.start(Phase::CommitApply);
11809        let was_dirty = self.overlay_dirty.snapshot();
11810        self.begin_observation("syncular_commit")
11811            .map_err(|message| ("storage.failed".into(), message))?;
11812        let applied = (|| {
11813            let mut batch = ChangeAccumulator::default();
11814            self.record_commit_changes(&mut batch, tables, changes);
11815            if let Some(sequence) = commit_seq {
11816                for change in changes {
11817                    let table = tables.get(change.table_index as usize).ok_or_else(|| {
11818                        (
11819                            "sync.invalid_request".to_owned(),
11820                            "change tableIndex out of range".to_owned(),
11821                        )
11822                    })?;
11823                    if self
11824                        .outbox
11825                        .iter()
11826                        .flat_map(|commit| &commit.ops)
11827                        .any(|op| op.table == *table && op.row_id == change.row_id)
11828                    {
11829                        self.conn.execute("INSERT INTO _syncular_row_deliveries(tbl,id,commit_seq) VALUES (?1,?2,?3) ON CONFLICT(tbl,id) DO UPDATE SET commit_seq=MAX(commit_seq,excluded.commit_seq)", rusqlite::params![table, change.row_id, sequence])
11830                        .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
11831                    }
11832                    self.conn.execute("DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3", rusqlite::params![table, change.row_id, sequence])
11833                        .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
11834                }
11835            }
11836            self.apply_commit_changes(tables, changes)?;
11837            self.rebuild_overlay_if_dirty();
11838            self.finish_observation("syncular_commit", batch)
11839                .map_err(|message| ("storage.failed".into(), message))
11840        })();
11841        if applied.is_err() {
11842            self.rollback_observation("syncular_commit");
11843            self.overlay_dirty.restore(was_dirty.clone());
11844        }
11845        applied
11846    }
11847
11848    fn apply_commit_changes(
11849        &mut self,
11850        tables: &[String],
11851        changes: &[ssp2::model::Change],
11852    ) -> Result<(), (String, String)> {
11853        // A clean overlay can reconcile changed primary keys independently
11854        // when their tables have no secondary unique constraints. Such a
11855        // constraint can change whether another row's pending write succeeds,
11856        // so those frames still require the complete FIFO replay below.
11857        let mirror_visible = !self.overlay_dirty.get()
11858            && self.failed_commits.is_empty()
11859            && ((self.outbox.is_empty() && !self.has_acknowledged_rows())
11860                || changes.iter().all(|change| {
11861                    tables
11862                        .get(change.table_index as usize)
11863                        .and_then(|name| self.schema.table(name))
11864                        .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
11865                }));
11866        for change in changes {
11867            let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
11868                (
11869                    "sync.invalid_request".to_owned(),
11870                    "change tableIndex out of range".to_owned(),
11871                )
11872            })?;
11873            let table = self.schema.table(table_name).ok_or_else(|| {
11874                (
11875                    "sync.schema_mismatch".to_owned(),
11876                    format!("change targets unknown table {table_name:?}"),
11877                )
11878            })?;
11879            match change.op {
11880                Op::Upsert => {
11881                    let payload = change.row.as_ref().ok_or_else(|| {
11882                        (
11883                            "sync.invalid_request".to_owned(),
11884                            "upsert change without row payload".to_owned(),
11885                        )
11886                    })?;
11887                    // §5.11: decrypt encrypted columns on apply.
11888                    let row = {
11889                        #[cfg(feature = "bench-internals")]
11890                        let _phase = self.benchmark_phases.start(Phase::RowDecode);
11891                        decode_row_bytes(table, payload, &self.encryption)
11892                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?
11893                    };
11894                    let version = change.row_version.unwrap_or(0);
11895                    let table_name = table.name.clone();
11896                    self.write_base_row(&table_name, &row, version)
11897                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
11898                    if mirror_visible {
11899                        self.write_row(&visible_table(&table_name), &table_name, &row, version)
11900                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
11901                    }
11902                }
11903                Op::Delete => {
11904                    self.delete_base_row(table_name, &change.row_id)
11905                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
11906                    if mirror_visible {
11907                        let sql = format!(
11908                            "DELETE FROM {} WHERE {}",
11909                            visible_table(table_name),
11910                            row_id_predicate(table)
11911                        );
11912                        self.conn
11913                            .prepare_cached(&sql)
11914                            .and_then(|mut statement| {
11915                                statement.execute(rusqlite::params![change.row_id])
11916                            })
11917                            .map_err(|error| {
11918                                ("sync.invalid_request".to_owned(), error.to_string())
11919                            })?;
11920                    }
11921                }
11922            }
11923        }
11924        if mirror_visible {
11925            {
11926                let changed_rows: HashSet<(&str, &str)> = changes
11927                    .iter()
11928                    .map(|change| {
11929                        (
11930                            tables[change.table_index as usize].as_str(),
11931                            change.row_id.as_str(),
11932                        )
11933                    })
11934                    .collect();
11935                let acknowledged = self
11936                    .acknowledged_operations(Some(&changed_rows))
11937                    .map_err(|error| ("storage.failed".to_owned(), error))?;
11938                self.apply_outbox_ops(&acknowledged);
11939                self.apply_outbox_ops(
11940                    self.outbox
11941                        .iter()
11942                        .flat_map(|commit| &commit.ops)
11943                        .filter(|op| {
11944                            changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
11945                        }),
11946                );
11947            }
11948            self.overlay_dirty.set(false);
11949        }
11950        Ok(())
11951    }
11952
11953    /// §5.6 segment application: validate against the generated schema,
11954    /// clear the grant on a fresh bootstrap's first page (fail closed
11955    /// without a mapping), then replace-or-upsert each row with its
11956    /// segment-carried server version (§5.2).
11957    fn apply_segment(
11958        &mut self,
11959        sub_index: usize,
11960        segment: &RowsSegment,
11961        first_fresh_page: bool,
11962    ) -> Result<u32, SectionError> {
11963        let (sub_table, effective) = {
11964            let sub = &self.subs[sub_index];
11965            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
11966        };
11967        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
11968            SectionError::Abort(
11969                "sync.schema_mismatch".to_owned(),
11970                format!("subscription table {sub_table:?} is not in the client schema"),
11971            )
11972        })?;
11973        // §5.2: the column table validates against the generated schema —
11974        // order, names, types, nullability; mismatch is fatal. §5.11: the
11975        // server sends the WIRE types (bytes for an encrypted column), so
11976        // validate against wire_columns.
11977        let matches = segment.table == table.name
11978            && segment.schema_version == self.schema.version
11979            && segment.columns.len() == table.wire_columns.len()
11980            && segment
11981                .columns
11982                .iter()
11983                .zip(table.wire_columns.iter())
11984                .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
11985        if !matches {
11986            return Err(SectionError::Abort(
11987                "sync.schema_mismatch".to_owned(),
11988                "segment column table does not match the generated schema (§5.2)".to_owned(),
11989            ));
11990        }
11991        let mut applied = 0u32;
11992        let empty = Vec::new();
11993        let blocks = if segment.blocks.is_empty() {
11994            std::slice::from_ref(&empty)
11995        } else {
11996            &segment.blocks
11997        };
11998        for (index, block) in blocks.iter().enumerate() {
11999            let clear = first_fresh_page && index == 0;
12000            if !clear && block.is_empty() {
12001                continue;
12002            }
12003            let was_dirty = self.overlay_dirty.snapshot();
12004            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
12005                && self
12006                    .outbox
12007                    .iter()
12008                    .flat_map(|commit| &commit.ops)
12009                    .any(|op| op.table == table.name);
12010            let mirror_visible = !reconcile_pending
12011                && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
12012            self.begin_observation("syncular_segment_block")
12013                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
12014            let outcome = (|| {
12015                self.rebuild_overlay_if_dirty();
12016                let mut changed_keys = Vec::new();
12017                let mut batch = ChangeAccumulator::default();
12018                if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
12019                    batch.table(&table.name);
12020                }
12021                if clear {
12022                    self.purge_scope_rows(&table.name, &effective)
12023                        .map_err(|()| SectionError::FailClosed)?;
12024                }
12025                for (row_index, row) in block.iter().enumerate() {
12026                    let decrypted;
12027                    let values = if table.has_encrypted_columns() {
12028                        let mut values = row.values.clone();
12029                        crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
12030                            .map_err(|message| {
12031                            SectionError::Abort("client.decrypt_failed".into(), message)
12032                        })?;
12033                        decrypted = values;
12034                        &decrypted
12035                    } else {
12036                        &row.values
12037                    };
12038                    self.write_base_row(&table.name, values, row.server_version)
12039                        .map_err(|message| {
12040                            SectionError::Abort("sync.invalid_request".into(), message)
12041                        })?;
12042                    if reconcile_pending {
12043                        changed_keys.push(
12044                            owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
12045                                |message| SectionError::Abort("storage.failed".into(), message),
12046                            )?,
12047                        );
12048                    }
12049                    if mirror_visible {
12050                        self.write_row(
12051                            &visible_table(&table.name),
12052                            &table.name,
12053                            values,
12054                            row.server_version,
12055                        )
12056                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
12057                    }
12058                    let processed = u64::from(applied) + row_index as u64 + 1;
12059                    if processed.is_multiple_of(1024) {
12060                        self.progress.update(|p| p.rows_processed = processed);
12061                    }
12062                }
12063                if reconcile_pending {
12064                    self.reconcile_imported_rows(&table, &mut changed_keys)
12065                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
12066                }
12067                if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
12068                    self.apply_outbox_ops(
12069                        self.outbox
12070                            .iter()
12071                            .flat_map(|commit| &commit.ops)
12072                            .filter(|op| op.table == table.name),
12073                    );
12074                    self.overlay_dirty.set(false);
12075                } else {
12076                    self.rebuild_overlay();
12077                }
12078                self.finish_observation("syncular_segment_block", batch)
12079                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))
12080            })();
12081            if let Err(error) = outcome {
12082                self.rollback_observation("syncular_segment_block");
12083                self.overlay_dirty.restore(was_dirty.clone());
12084                return Err(error);
12085            }
12086            applied += block.len() as u32;
12087            self.progress
12088                .update(|p| p.rows_processed = u64::from(applied));
12089            std::thread::yield_now();
12090        }
12091        self.progress
12092            .update(|p| p.rows_processed = u64::from(applied));
12093        Ok(applied)
12094    }
12095
12096    /// §5.3 sqlite-image application: validate the in-file metadata
12097    /// against the descriptor, validate column names/order against the
12098    /// generated schema, run the §5.6 first-page clear when fresh, then
12099    /// replace-or-upsert every image row with its `_syncular_version`.
12100    /// Mechanics: the image lands in a temp file read through a second
12101    /// rusqlite connection, equivalent to ATTACH + INSERT…SELECT.
12102    /// Each image chunk commits its writes and revision before readers continue.
12103    fn apply_sqlite_segment(
12104        &mut self,
12105        sub_index: usize,
12106        bytes: &[u8],
12107        first_fresh_page: bool,
12108        row_count: i64,
12109        as_of_commit_seq: i64,
12110        scope_digest: &str,
12111    ) -> Result<u32, SectionError> {
12112        let invalid = |detail: &str| {
12113            SectionError::Abort(
12114                "sync.invalid_request".to_owned(),
12115                format!("sqlite segment rejected: {detail} (§5.3)"),
12116            )
12117        };
12118        let (sub_table, effective) = {
12119            let sub = &self.subs[sub_index];
12120            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
12121        };
12122        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
12123            SectionError::Abort(
12124                "sync.schema_mismatch".to_owned(),
12125                format!("subscription table {sub_table:?} is not in the client schema"),
12126            )
12127        })?;
12128
12129        let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
12130        std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
12131        let img = match rusqlite::Connection::open_with_flags(
12132            &path,
12133            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
12134        ) {
12135            Ok(conn) => conn,
12136            Err(_) => {
12137                let _ = std::fs::remove_file(&path);
12138                return Err(invalid("bytes do not open as a SQLite database"));
12139            }
12140        };
12141        let outcome = self.apply_sqlite_image(
12142            &img,
12143            &table,
12144            first_fresh_page,
12145            &effective,
12146            row_count,
12147            as_of_commit_seq,
12148            scope_digest,
12149        );
12150        drop(img);
12151        let _ = std::fs::remove_file(&path);
12152        outcome
12153    }
12154
12155    #[allow(clippy::too_many_arguments)]
12156    fn apply_sqlite_image(
12157        &mut self,
12158        img: &rusqlite::Connection,
12159        table: &crate::schema::TableSchema,
12160        first_fresh_page: bool,
12161        effective: &[(String, Vec<String>)],
12162        row_count: i64,
12163        as_of_commit_seq: i64,
12164        scope_digest: &str,
12165    ) -> Result<u32, SectionError> {
12166        let invalid = |detail: String| {
12167            SectionError::Abort(
12168                "sync.invalid_request".to_owned(),
12169                format!("sqlite segment rejected: {detail} (§5.3)"),
12170            )
12171        };
12172
12173        // 1. Metadata vs descriptor + client state (§5.3 rule 2; exactly
12174        //    one row).
12175        type MetaRow = (i64, String, i64, i64, String, i64, i64);
12176        let meta: MetaRow = img
12177            .query_row(
12178                "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
12179                        \"scopeDigest\", \"rowCount\",
12180                        (SELECT count(*) FROM _syncular_segment)
12181                 FROM _syncular_segment",
12182                [],
12183                |row| {
12184                    Ok((
12185                        row.get(0)?,
12186                        row.get(1)?,
12187                        row.get(2)?,
12188                        row.get(3)?,
12189                        row.get(4)?,
12190                        row.get(5)?,
12191                        row.get(6)?,
12192                    ))
12193                },
12194            )
12195            .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
12196        let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
12197        if meta_count != 1 {
12198            return Err(invalid(format!(
12199                "_syncular_segment must contain exactly one row, found {meta_count}"
12200            )));
12201        }
12202        if format != 1 {
12203            return Err(invalid(format!("format {format}")));
12204        }
12205        if meta_table != table.name {
12206            return Err(invalid(format!("image table {meta_table:?}")));
12207        }
12208        if schema_version != i64::from(self.schema.version) {
12209            return Err(invalid(format!("schemaVersion {schema_version}")));
12210        }
12211        if pin != as_of_commit_seq {
12212            return Err(invalid(format!("asOfCommitSeq {pin}")));
12213        }
12214        if digest != scope_digest {
12215            return Err(invalid("scopeDigest mismatch".to_owned()));
12216        }
12217        if meta_rows != row_count {
12218            return Err(invalid(format!("rowCount {meta_rows}")));
12219        }
12220
12221        // 2. Column names and order vs the generated schema (§5.3 rule 3).
12222        let mut names: Vec<String> = Vec::new();
12223        let mut primary_keys: Vec<String> = Vec::new();
12224        {
12225            let mut stmt = img
12226                .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
12227                .map_err(|_| invalid("image data table missing".to_owned()))?;
12228            let mut rows = stmt
12229                .query([])
12230                .map_err(|_| invalid("image data table unreadable".to_owned()))?;
12231            while let Some(row) = rows
12232                .next()
12233                .map_err(|_| invalid("image data table unreadable".to_owned()))?
12234            {
12235                if row
12236                    .get::<_, i64>(5)
12237                    .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?
12238                    > 0
12239                {
12240                    primary_keys.push(row.get(1).map_err(|error| {
12241                        invalid(Self::sqlite_failure(&self.storage_failure, error))
12242                    })?);
12243                }
12244                names.push(
12245                    row.get::<_, String>(1)
12246                        .map_err(|_| invalid("image data table unreadable".to_owned()))?,
12247                );
12248            }
12249        }
12250        let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
12251        expected.push("_syncular_version");
12252        if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
12253            return Err(SectionError::Abort(
12254                "sync.schema_mismatch".to_owned(),
12255                "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
12256            ));
12257        }
12258
12259        if primary_keys != vec![table.primary_key.clone()] {
12260            return Err(invalid(
12261                "image primary key does not match generated schema".into(),
12262            ));
12263        }
12264        let actual_count: i64 = img
12265            .query_row(
12266                &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
12267                [],
12268                |row| row.get(0),
12269            )
12270            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
12271        if actual_count != row_count {
12272            return Err(invalid("image row count does not match descriptor".into()));
12273        }
12274        let insert = self.insert_row_sql(&base_table(&table.name), table);
12275        let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
12276        let column_list = names
12277            .iter()
12278            .map(|n| quote_ident(n))
12279            .collect::<Vec<_>>()
12280            .join(", ");
12281        let mut stmt = img
12282            .prepare(&format!(
12283                "SELECT {column_list} FROM {} ORDER BY {}",
12284                quote_ident(&table.name),
12285                quote_ident(&table.primary_key)
12286            ))
12287            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
12288        let mut rows = stmt
12289            .query([])
12290            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
12291        let mut applied = 0u32;
12292        loop {
12293            let clear = applied == 0 && first_fresh_page;
12294            let was_dirty = self.overlay_dirty.snapshot();
12295            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
12296                && self
12297                    .outbox
12298                    .iter()
12299                    .flat_map(|commit| &commit.ops)
12300                    .any(|op| op.table == table.name);
12301            let mirror_visible = !reconcile_pending
12302                && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
12303            self.begin_observation("syncular_image_chunk")
12304                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
12305            let result = (|| {
12306                self.rebuild_overlay_if_dirty();
12307                let mut changed_keys = Vec::new();
12308                let mut batch = ChangeAccumulator::default();
12309                if clear {
12310                    if self.scoped_rows_exist(&table.name, effective) {
12311                        batch.table(&table.name);
12312                    }
12313                    self.purge_scope_rows(&table.name, effective)
12314                        .map_err(|()| SectionError::FailClosed)?;
12315                }
12316                let mut processed = 0u32;
12317                {
12318                    let mut ins = self.conn.prepare_cached(&insert).map_err(|error| {
12319                        invalid(Self::sqlite_failure(&self.storage_failure, error))
12320                    })?;
12321                    let mut mirror = if mirror_visible {
12322                        Some(self.conn.prepare_cached(&visible_insert).map_err(|error| {
12323                            invalid(Self::sqlite_failure(&self.storage_failure, error))
12324                        })?)
12325                    } else {
12326                        None
12327                    };
12328                    for _ in 0..1024 {
12329                        let Some(row) = rows.next().map_err(|error| {
12330                            invalid(Self::sqlite_failure(&self.storage_failure, error))
12331                        })?
12332                        else {
12333                            break;
12334                        };
12335                        for (i, column) in table.columns.iter().enumerate() {
12336                            let cell = row.get_ref(i).map_err(|error| {
12337                                invalid(Self::sqlite_failure(&self.storage_failure, error))
12338                            })?;
12339                            let param = image_cell_param(column, cell).map_err(invalid)?;
12340                            ins.raw_bind_parameter(i + 1, &param).map_err(|error| {
12341                                invalid(Self::sqlite_failure(&self.storage_failure, error))
12342                            })?;
12343                            if let Some(mirror) = &mut mirror {
12344                                mirror.raw_bind_parameter(i + 1, &param).map_err(|error| {
12345                                    invalid(Self::sqlite_failure(&self.storage_failure, error))
12346                                })?;
12347                            }
12348                        }
12349                        let version_index = table.columns.len();
12350                        let version: i64 = row.get(version_index).map_err(|error| {
12351                            invalid(Self::sqlite_failure(&self.storage_failure, error))
12352                        })?;
12353                        if version < 1 {
12354                            return Err(invalid("image row version must be positive".into()));
12355                        }
12356                        ins.raw_bind_parameter(version_index + 1, version)
12357                            .map_err(|error| {
12358                                invalid(Self::sqlite_failure(&self.storage_failure, error))
12359                            })?;
12360                        ins.raw_execute().map_err(|error| {
12361                            invalid(Self::sqlite_failure(&self.storage_failure, error))
12362                        })?;
12363                        if let Some(mirror) = &mut mirror {
12364                            mirror
12365                                .raw_bind_parameter(version_index + 1, version)
12366                                .map_err(|error| {
12367                                    invalid(Self::sqlite_failure(&self.storage_failure, error))
12368                                })?;
12369                            mirror.raw_execute().map_err(|error| {
12370                                invalid(Self::sqlite_failure(&self.storage_failure, error))
12371                            })?;
12372                        }
12373                        if reconcile_pending {
12374                            changed_keys.push(row.get::<_, SqlValue>(table.pk_index).map_err(
12375                                |error| invalid(Self::sqlite_failure(&self.storage_failure, error)),
12376                            )?);
12377                        }
12378                        processed += 1;
12379                    }
12380                }
12381                if processed > 0 {
12382                    batch.table(&table.name);
12383                    self.overlay_dirty.table(&table.name);
12384                }
12385                if reconcile_pending {
12386                    self.reconcile_imported_rows(table, &mut changed_keys)
12387                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
12388                }
12389                if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
12390                    self.apply_outbox_ops(
12391                        self.outbox
12392                            .iter()
12393                            .flat_map(|commit| &commit.ops)
12394                            .filter(|op| op.table == table.name),
12395                    );
12396                    self.overlay_dirty.set(false);
12397                } else {
12398                    self.rebuild_overlay();
12399                }
12400                self.finish_observation("syncular_image_chunk", batch)
12401                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
12402                Ok(processed)
12403            })();
12404            let processed = match result {
12405                Ok(processed) => processed,
12406                Err(error) => {
12407                    self.rollback_observation("syncular_image_chunk");
12408                    self.overlay_dirty.restore(was_dirty.clone());
12409                    return Err(error);
12410                }
12411            };
12412            applied += processed;
12413            self.progress
12414                .update(|p| p.rows_processed = u64::from(applied));
12415            std::thread::yield_now();
12416            if i64::from(applied) == actual_count {
12417                break;
12418            }
12419        }
12420        Ok(applied)
12421    }
12422
12423    /// Restore only imported and pending row identities before FIFO replay. All pending
12424    /// identities participate: a server row can free a unique value for a different edit.
12425    /// The caller owns the chunk transaction and publishes its revision after replay.
12426    fn reconcile_imported_rows(
12427        &self,
12428        table: &TableSchema,
12429        keys: &mut Vec<SqlValue>,
12430    ) -> Result<(), String> {
12431        let visible = visible_table(&table.name);
12432        let base = base_table(&table.name);
12433        let pk = quote_ident(&table.primary_key);
12434        let predicate = row_id_predicate(table);
12435        let mut lookup = self.conn.prepare_cached(&format!(
12436            "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
12437        )).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12438        let mut deleted = HashSet::new();
12439        for op in self
12440            .outbox
12441            .iter()
12442            .flat_map(|commit| &commit.ops)
12443            .filter(|op| op.table == table.name)
12444        {
12445            if op.upsert {
12446                if let Some(values) = &op.values {
12447                    let key = json_to_column_value(
12448                        &table.columns[table.pk_index],
12449                        values.get(&table.primary_key),
12450                    )?;
12451                    keys.push(owned_sql_value(RowParam::Cell(&key))?);
12452                }
12453            } else if deleted.insert(&op.row_id) {
12454                keys.extend(
12455                    lookup
12456                        .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
12457                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
12458                        .collect::<Result<Vec<_>, _>>()
12459                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
12460                );
12461            }
12462        }
12463        // Delete the complete affected set before restoring any base value, so
12464        // old optimistic unique values cannot collide with the new server rows.
12465        for chunk in keys.chunks(400) {
12466            let holes = vec!["?"; chunk.len()].join(",");
12467            self.conn
12468                .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
12469                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
12470                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12471        }
12472        let updates = table
12473            .columns
12474            .iter()
12475            .map(|column| column.name.as_str())
12476            .chain(std::iter::once("_syncular_version"))
12477            .filter(|name| *name != table.primary_key)
12478            .map(|name| {
12479                let name = quote_ident(name);
12480                format!("{name}=excluded.{name}")
12481            })
12482            .collect::<Vec<_>>()
12483            .join(",");
12484        for chunk in keys.chunks(400) {
12485            let holes = vec!["?"; chunk.len()].join(",");
12486            self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
12487                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
12488                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12489        }
12490        Ok(())
12491    }
12492
12493    // -- application-authorized local purge ---------------------------------------
12494
12495    fn compile_local_data_purge(
12496        &self,
12497        input: &LocalDataPurgeInput,
12498    ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
12499        let invalid = |message: String| format!("sync.invalid_request: {message}");
12500        if input.purge_id.is_empty()
12501            || input.purge_id.len() > 128
12502            || !is_local_operation_code_like(&input.purge_id)
12503        {
12504            return Err(invalid(
12505                "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
12506            ));
12507        }
12508        if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
12509            return Err(invalid(format!(
12510                "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
12511            )));
12512        }
12513
12514        let mut deduplicated: BTreeMap<
12515            String,
12516            (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
12517        > = BTreeMap::new();
12518        for target in &input.targets {
12519            let table = self.schema.table(&target.table).ok_or_else(|| {
12520                invalid(format!(
12521                    "local purge names unknown table {:?}",
12522                    target.table
12523                ))
12524            })?;
12525            if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
12526                return Err(invalid(format!(
12527                    "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
12528                    target.table
12529                )));
12530            }
12531            let mut selectors = Vec::with_capacity(target.selectors.len());
12532            let mut canonical_selectors = BTreeMap::new();
12533            for (column_name, raw_values) in &target.selectors {
12534                let Some((column_index, column)) = table
12535                    .columns
12536                    .iter()
12537                    .enumerate()
12538                    .find(|(_, column)| column.name == *column_name)
12539                else {
12540                    return Err(invalid(format!(
12541                        "local purge target {:?} names unknown column {:?}",
12542                        target.table, column_name
12543                    )));
12544                };
12545                let encrypted = table
12546                    .encrypted_columns
12547                    .iter()
12548                    .any(|candidate| candidate.index == column_index);
12549                if column.ty != ColumnType::String || encrypted {
12550                    return Err(invalid(format!(
12551                        "local purge selector {:?}.{:?} must be a plaintext string column",
12552                        target.table, column_name
12553                    )));
12554                }
12555                if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
12556                    return Err(invalid(format!(
12557                        "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
12558                        target.table, column_name
12559                    )));
12560                }
12561                let mut values = raw_values.clone();
12562                values.sort();
12563                values.dedup();
12564                if values.iter().any(|value| {
12565                    value.is_empty()
12566                        || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
12567                        || !is_local_operation_code_like(value)
12568                }) {
12569                    return Err(invalid(format!(
12570                        "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
12571                    )));
12572                }
12573                selectors.push((column_name.clone(), values.clone()));
12574                canonical_selectors.insert(column_name.clone(), values);
12575            }
12576            let canonical = LocalDataPurgeTarget {
12577                table: target.table.clone(),
12578                selectors: canonical_selectors,
12579            };
12580            let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
12581            deduplicated.insert(
12582                key,
12583                (
12584                    CompiledLocalDataPurgeTarget {
12585                        table: target.table.clone(),
12586                        selectors,
12587                    },
12588                    canonical,
12589                ),
12590            );
12591        }
12592        let targets = deduplicated
12593            .values()
12594            .map(|(compiled, _)| compiled.clone())
12595            .collect::<Vec<_>>();
12596        let canonical = deduplicated
12597            .values()
12598            .map(|(_, target)| target.clone())
12599            .collect::<Vec<_>>();
12600        let canonical_plan =
12601            serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
12602        Ok((targets, canonical_plan))
12603    }
12604
12605    fn local_purge_base_row_ids(
12606        &self,
12607        targets: &[CompiledLocalDataPurgeTarget],
12608    ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
12609        let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
12610        for target in targets {
12611            let table = self
12612                .schema
12613                .table(&target.table)
12614                .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
12615            let mut clauses = Vec::with_capacity(target.selectors.len());
12616            let mut params = Vec::new();
12617            for (column, values) in &target.selectors {
12618                clauses.push(format!(
12619                    "{} IN ({})",
12620                    quote_ident(column),
12621                    values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
12622                ));
12623                params.extend(values.iter().cloned().map(SqlValue::Text));
12624            }
12625            let sql = format!(
12626                "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
12627                quote_ident(&table.primary_key),
12628                base_table(&target.table),
12629                clauses.join(" AND ")
12630            );
12631            let mut statement = self
12632                .conn
12633                .prepare(&sql)
12634                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12635            let rows = statement
12636                .query_map(rusqlite::params_from_iter(params), |row| {
12637                    let value = sql_ref_to_json(&table.columns[table.pk_index], row.get_ref(0)?);
12638                    render_row_id_json(Some(&value))
12639                        .map_err(|message| rusqlite::Error::ToSqlConversionFailure(message.into()))
12640                })
12641                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12642            let ids = by_table.entry(target.table.clone()).or_default();
12643            for row in rows {
12644                ids.insert(
12645                    row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
12646                );
12647            }
12648        }
12649        Ok(by_table)
12650    }
12651
12652    fn local_purge_values_match(
12653        target: &CompiledLocalDataPurgeTarget,
12654        values: &Map<String, Value>,
12655    ) -> bool {
12656        target.selectors.iter().all(|(column, allowed)| {
12657            values
12658                .get(column)
12659                .and_then(Value::as_str)
12660                .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
12661        })
12662    }
12663
12664    /// Apply one host-authorized local security purge. The host MUST gate the
12665    /// corresponding subscriptions first; this primitive owns local SQLite
12666    /// cleanup and never grants/revokes server authority by itself.
12667    pub fn purge_local_data(
12668        &mut self,
12669        input: &LocalDataPurgeInput,
12670    ) -> Result<LocalDataPurgeResult, String> {
12671        let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
12672        self.cancel_sync_round();
12673        let meta_key = format!("localPurge:{}", input.purge_id);
12674        let applied_plan = self
12675            .conn
12676            .query_row(
12677                "SELECT value FROM _syncular_meta WHERE key = ?1",
12678                rusqlite::params![meta_key],
12679                |row| row.get::<_, String>(0),
12680            )
12681            .optional()
12682            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12683        if let Some(applied_plan) = applied_plan {
12684            if applied_plan != canonical_plan {
12685                return Err(format!(
12686                    "sync.invalid_request: local purge id {:?} was already used with a different plan",
12687                    input.purge_id
12688                ));
12689            }
12690            return Ok(LocalDataPurgeResult {
12691                already_applied: true,
12692                purged_rows: 0,
12693                dropped_commits: 0,
12694            });
12695        }
12696
12697        // RFC 0005 D7: the container file cannot be a purge target (its name is
12698        // not in the running schema), so purge drops it as a fixed step, before
12699        // any mirror row is touched and unconditionally.
12700        let _ = self.drop_previous_version();
12701
12702        let prior_outbox = self.outbox.clone();
12703        let prior_failed = self.failed_commits.clone();
12704        let prior_rejections = self.rejections.clone();
12705        let prior_conflicts = self.conflicts.clone();
12706        let prior_overlay_dirty = self.overlay_dirty.snapshot();
12707        self.begin_observation("syncular_local_purge")?;
12708        let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
12709            let row_ids = self.local_purge_base_row_ids(&targets)?;
12710            let retained_dropped = self.drop_failed_commits(|table, values| {
12711                targets.iter().any(|target| {
12712                    target.table == table && Self::local_purge_values_match(target, values)
12713                })
12714            })?;
12715            let doomed = self
12716                .outbox
12717                .iter()
12718                .filter(|commit| {
12719                    commit.ops.iter().any(|operation| {
12720                        let matching_targets = targets
12721                            .iter()
12722                            .filter(|target| target.table == operation.table)
12723                            .collect::<Vec<_>>();
12724                        if matching_targets.is_empty() {
12725                            return false;
12726                        }
12727                        if row_ids
12728                            .get(&operation.table)
12729                            .is_some_and(|ids| ids.contains(&operation.row_id))
12730                        {
12731                            return true;
12732                        }
12733                        operation.values.as_ref().is_some_and(|values| {
12734                            matching_targets
12735                                .iter()
12736                                .any(|target| Self::local_purge_values_match(target, values))
12737                        })
12738                    })
12739                })
12740                .cloned()
12741                .collect::<Vec<_>>();
12742            let doomed_ids = doomed
12743                .iter()
12744                .map(|commit| commit.client_commit_id.clone())
12745                .collect::<BTreeSet<_>>();
12746            let mut batch = ChangeAccumulator::default();
12747            for commit in &retained_dropped {
12748                batch.conflicts = true;
12749                batch.rejections = true;
12750                batch.outcomes = true;
12751                for operation in &commit.ops {
12752                    batch.table(&operation.table);
12753                }
12754            }
12755            let mut rejections = Vec::new();
12756            for commit in &doomed {
12757                for operation in &commit.ops {
12758                    batch.table(&operation.table);
12759                }
12760                let results = commit
12761                    .ops
12762                    .iter()
12763                    .enumerate()
12764                    .map(|(op_index, operation)| {
12765                        let rejection = RejectionRecord {
12766                            client_commit_id: commit.client_commit_id.clone(),
12767                            op_index: op_index as i32,
12768                            code: "client.local_data_purged".to_owned(),
12769                            message: "the commit was dropped by an application-authorized local data purge".to_owned(),
12770                            retryable: false,
12771                            details: None,
12772                            operation: Some(CommitOperation::from(operation)),
12773                        };
12774                        rejections.push(rejection.clone());
12775                        CommitOperationOutcome::Error { rejection }
12776                    })
12777                    .collect::<Vec<_>>();
12778                self.persist_commit_outcome(
12779                    &commit.client_commit_id,
12780                    CommitOutcomeStatus::Rejected,
12781                    &results,
12782                    Some(&commit.ops),
12783                )?;
12784                self.delete_outbox_persisted(&commit.client_commit_id)?;
12785            }
12786            if !doomed.is_empty() {
12787                self.outbox
12788                    .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
12789                self.rejections.extend(rejections);
12790                self.prune_commit_outcomes()?;
12791                batch.status = true;
12792                batch.rejections = true;
12793                batch.outcomes = true;
12794            }
12795
12796            let mut purged_rows = 0usize;
12797            for (table, ids) in &row_ids {
12798                if ids.is_empty() {
12799                    continue;
12800                }
12801                batch.table(table);
12802                for row_id in ids {
12803                    self.delete_base_row(table, row_id)?;
12804                    purged_rows += 1;
12805                }
12806            }
12807            self.rebuild_overlay_if_dirty();
12808            self.delete_unreferenced_cached_blobs();
12809            self.conn
12810                .execute(
12811                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
12812                    rusqlite::params![meta_key, canonical_plan],
12813                )
12814                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12815            Ok((
12816                batch,
12817                LocalDataPurgeResult {
12818                    already_applied: false,
12819                    purged_rows,
12820                    dropped_commits: doomed.len(),
12821                },
12822            ))
12823        })();
12824        let (batch, result) = match applied {
12825            Ok(value) => value,
12826            Err(error) => {
12827                self.rollback_observation("syncular_local_purge");
12828                self.outbox = prior_outbox;
12829                self.failed_commits = prior_failed;
12830                self.rejections = prior_rejections;
12831                self.conflicts = prior_conflicts;
12832                self.overlay_dirty.restore(prior_overlay_dirty.clone());
12833                return Err(error);
12834            }
12835        };
12836        if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
12837            self.rollback_observation("syncular_local_purge");
12838            self.outbox = prior_outbox;
12839            self.failed_commits = prior_failed;
12840            self.rejections = prior_rejections;
12841            self.conflicts = prior_conflicts;
12842            self.overlay_dirty.restore(prior_overlay_dirty.clone());
12843            return Err(error);
12844        }
12845        Ok(result)
12846    }
12847
12848    /// Application-authorized recovery of the replicated projection. Unlike a
12849    /// local security purge, this retains the entire outbox, device identity,
12850    /// lease, outcomes, subscription registrations, and protected bookkeeping.
12851    /// The projection reset, subscription rewind, optimistic replay, and
12852    /// idempotency marker share one savepoint for interruption safety.
12853    pub fn rebootstrap_local_data(
12854        &mut self,
12855        input: &LocalDataRebootstrapInput,
12856    ) -> Result<LocalDataRebootstrapResult, String> {
12857        if input.rebootstrap_id.is_empty()
12858            || input.rebootstrap_id.len() > 128
12859            || !is_local_operation_code_like(&input.rebootstrap_id)
12860        {
12861            return Err(
12862                "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
12863                    .to_owned(),
12864            );
12865        }
12866        let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
12867        if let Some(persisted) = self.get_meta_strict(&meta_key)? {
12868            let (retained_commits, reset_subscriptions) =
12869                decode_local_rebootstrap_receipt(&persisted)?;
12870            return Ok(LocalDataRebootstrapResult {
12871                already_applied: true,
12872                retained_commits,
12873                reset_subscriptions,
12874            });
12875        }
12876        if self.stopped || self.schema_floor.is_some() {
12877            return Err(
12878                "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
12879                    .to_owned(),
12880            );
12881        }
12882
12883        let retained_commits = self.outbox.len();
12884        let reset_subscriptions = self.subs.len();
12885        let prior_subs = self.subs.clone();
12886        let prior_upgrading = self.upgrading;
12887        let prior_stopped = self.stopped;
12888        let prior_schema_floor = self.schema_floor.clone();
12889        let prior_overlay_dirty = self.overlay_dirty.snapshot();
12890        let prior_sync_needed = self.sync_needed;
12891        let prior_sync_intents = self.sync_intent_queue.clone();
12892        let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
12893
12894        self.cancel_sync_round();
12895        self.begin_observation("syncular_local_rebootstrap")?;
12896        let mut batch = ChangeAccumulator::default();
12897        let applied = (|| -> Result<(), String> {
12898            self.run_schema_reset_observed(&mut batch, false, false)?;
12899            self.conn
12900                .execute(
12901                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
12902                    rusqlite::params![meta_key, receipt],
12903                )
12904                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12905            self.sync_needed = true;
12906            self.sync_intent_queue.push_back(SyncIntent::Interactive);
12907            batch.status = true;
12908            Ok(())
12909        })();
12910        if let Err(error) = applied {
12911            self.rollback_observation("syncular_local_rebootstrap");
12912            self.subs = prior_subs;
12913            self.upgrading = prior_upgrading;
12914            self.stopped = prior_stopped;
12915            self.schema_floor = prior_schema_floor;
12916            self.overlay_dirty.restore(prior_overlay_dirty.clone());
12917            self.sync_needed = prior_sync_needed;
12918            self.sync_intent_queue = prior_sync_intents;
12919            return Err(error);
12920        }
12921        if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
12922            self.rollback_observation("syncular_local_rebootstrap");
12923            self.subs = prior_subs;
12924            self.upgrading = prior_upgrading;
12925            self.stopped = prior_stopped;
12926            self.schema_floor = prior_schema_floor;
12927            self.overlay_dirty.restore(prior_overlay_dirty.clone());
12928            self.sync_needed = prior_sync_needed;
12929            self.sync_intent_queue = prior_sync_intents;
12930            return Err(error);
12931        }
12932
12933        Ok(LocalDataRebootstrapResult {
12934            already_applied: false,
12935            retained_commits,
12936            reset_subscriptions,
12937        })
12938    }
12939
12940    // -- scope purge + doomed outbox (§3.3) ----------------------------------------
12941
12942    /// Delete base rows matching the effective scopes; `Err(())` = no local
12943    /// scope-column mapping for a key (the fail-closed case).
12944    fn purge_scope_rows(
12945        &mut self,
12946        table_name: &str,
12947        effective: &[(String, Vec<String>)],
12948    ) -> Result<(), ()> {
12949        if effective.is_empty() {
12950            return Ok(());
12951        }
12952        let table = self.schema.table(table_name).ok_or(())?.clone();
12953        let mut clauses = Vec::new();
12954        let mut params: Vec<SqlValue> = Vec::new();
12955        for (variable, values) in effective {
12956            let column = table.scope_column(variable).ok_or(())?;
12957            let placeholders: Vec<String> = values
12958                .iter()
12959                .map(|v| {
12960                    params.push(SqlValue::Text(v.clone()));
12961                    "?".to_owned()
12962                })
12963                .collect();
12964            clauses.push(format!(
12965                "{} IN ({})",
12966                quote_ident(column),
12967                placeholders.join(", ")
12968            ));
12969        }
12970        let sql = format!(
12971            "DELETE FROM {} WHERE {}",
12972            base_table(table_name),
12973            clauses.join(" AND ")
12974        );
12975        self.overlay_dirty.table(table_name);
12976        self.conn
12977            .execute(&sql, rusqlite::params_from_iter(&params))
12978            .map_err(|_| ())?;
12979        self.conn
12980            .execute(
12981                &format!(
12982                    "DELETE FROM {} WHERE {}",
12983                    visible_table(table_name),
12984                    clauses.join(" AND ")
12985                ),
12986                rusqlite::params_from_iter(&params),
12987            )
12988            .map_err(|_| ())?;
12989        Ok(())
12990    }
12991
12992    /// §3.3: drop pending commits whose upserts provably land in the
12993    /// revoked effective scopes — whole-commit, never per-operation.
12994    fn drop_doomed_outbox(
12995        &mut self,
12996        table_name: &str,
12997        effective: &[(String, Vec<String>)],
12998    ) -> Result<bool, String> {
12999        if effective.is_empty() {
13000            return Ok(false);
13001        }
13002        let Some(table) = self.schema.table(table_name).cloned() else {
13003            return Ok(false);
13004        };
13005        let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
13006        for (variable, values) in effective {
13007            match table.scope_column(variable) {
13008                Some(column) => mappings.push((column, values)),
13009                None => return Ok(false), // not provable without a mapping
13010            }
13011        }
13012        let doomed: Vec<OutboxCommit> = self
13013            .outbox
13014            .iter()
13015            .filter(|commit| {
13016                commit.ops.iter().any(|op| {
13017                    op.upsert
13018                        && op.table == table_name
13019                        && op.values.as_ref().is_some_and(|values| {
13020                            mappings
13021                                .iter()
13022                                .all(|(column, allowed)| match values.get(*column) {
13023                                    Some(Value::String(s)) => allowed.contains(s),
13024                                    Some(Value::Number(n)) => allowed.contains(&n.to_string()),
13025                                    _ => false,
13026                                })
13027                        })
13028                })
13029            })
13030            .cloned()
13031            .collect();
13032        if doomed.is_empty() {
13033            return Ok(false);
13034        }
13035        let mut rejections = Vec::new();
13036        for commit in &doomed {
13037            let results = commit
13038                .ops
13039                .iter()
13040                .enumerate()
13041                .map(|(op_index, operation)| {
13042                    let rejection = RejectionRecord {
13043                        client_commit_id: commit.client_commit_id.clone(),
13044                        op_index: op_index as i32,
13045                        code: "sync.scope_revoked".to_owned(),
13046                        message: "the commit was dropped because its effective scope was revoked"
13047                            .to_owned(),
13048                        retryable: false,
13049                        details: None,
13050                        operation: Some(CommitOperation::from(operation)),
13051                    };
13052                    rejections.push(rejection.clone());
13053                    CommitOperationOutcome::Error { rejection }
13054                })
13055                .collect::<Vec<_>>();
13056            self.persist_commit_outcome(
13057                &commit.client_commit_id,
13058                CommitOutcomeStatus::Rejected,
13059                &results,
13060                Some(&commit.ops),
13061            )?;
13062            self.delete_outbox_persisted(&commit.client_commit_id)?;
13063        }
13064        self.prune_commit_outcomes()?;
13065        let doomed_ids = doomed
13066            .iter()
13067            .map(|commit| commit.client_commit_id.as_str())
13068            .collect::<BTreeSet<_>>();
13069        self.outbox
13070            .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
13071        self.rejections.extend(rejections);
13072        Ok(true)
13073    }
13074
13075    // -- blobs (§5.9) ----------------------------------------------------------------
13076
13077    /// §5.9.7: hash bytes into the content address, cache them, queue the
13078    /// upload (flushed before the next push, B4). Returns the canonical
13079    /// BlobRef JSON `{blobId, byteLength, mediaType?}` for a `blob_ref`
13080    /// column value.
13081    pub fn upload_blob(
13082        &mut self,
13083        bytes: &[u8],
13084        media_type: Option<String>,
13085        name: Option<String>,
13086    ) -> Result<Value, String> {
13087        let blob_id = blob_id_for(bytes);
13088        let now = self.clock_now_ms();
13089        let tx = self
13090            .conn
13091            .transaction()
13092            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13093        tx.execute(
13094            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
13095             ON CONFLICT(blob_id) DO NOTHING",
13096            rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
13097        )
13098        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13099        tx.execute(
13100            "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
13101            rusqlite::params![blob_id, media_type, now],
13102        )
13103        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13104        tx.commit()
13105            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13106        self.enforce_blob_cache_cap();
13107        let mut obj = Map::new();
13108        obj.insert("blobId".to_owned(), Value::from(blob_id));
13109        obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
13110        if let Some(mt) = media_type {
13111            obj.insert("mediaType".to_owned(), Value::from(mt));
13112        }
13113        if let Some(n) = name {
13114            obj.insert("name".to_owned(), Value::from(n));
13115        }
13116        Ok(Value::Object(obj))
13117    }
13118
13119    /// §5.9.7: resolve an owned blob body without driver encoding. A
13120    /// content-addressed cache hit serves with no fetch (B1); a miss downloads
13121    /// (§5.9.5), verifies the address, caches the bytes, and returns them.
13122    pub fn fetch_blob_bytes(
13123        &mut self,
13124        transport: &mut dyn Transport,
13125        blob_id_or_ref: &str,
13126    ) -> Result<FetchedBlob, (String, String)> {
13127        let simple = |m: String| ("client.failed".to_owned(), m);
13128        let blob_id = if blob_id_or_ref.starts_with("sha256:") {
13129            blob_id_or_ref.to_owned()
13130        } else {
13131            let value: Value = serde_json::from_str(blob_id_or_ref)
13132                .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
13133            value
13134                .get("blobId")
13135                .and_then(Value::as_str)
13136                .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
13137                .to_owned()
13138        };
13139        if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
13140            return Ok(cached);
13141        }
13142        if !self.transport_enabled {
13143            return Err(("sync.offline".into(), "transport gate is closed".into()));
13144        }
13145        // §5.9.5: propagate the server's blob.* code (blob.forbidden /
13146        // blob.not_found) verbatim so the harness can assert on it. The
13147        // authorized endpoint serves bytes inline OR (always-issue, presign
13148        // configured) a signed url the client fetches directly — no host auth,
13149        // no fall-through: failure => re-request (the caller's next fetch_blob_bytes).
13150        #[cfg(feature = "bench-internals")]
13151        let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
13152        let bytes = match transport
13153            .blob_download(&blob_id)
13154            .map_err(|e| (e.code, e.message))?
13155        {
13156            BlobDownload::Bytes(bytes) => bytes,
13157            BlobDownload::Url {
13158                url,
13159                url_expires_at_ms,
13160            } => {
13161                // §5.9.5: MUST NOT start a fetch at/past expiry.
13162                if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
13163                    return Err((
13164                        "sync.segment_expired".to_owned(),
13165                        format!(
13166                            "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
13167                        ),
13168                    ));
13169                }
13170                transport
13171                    .fetch_blob_url(&url)
13172                    .map_err(|e| (e.code, e.message))?
13173            }
13174        };
13175        #[cfg(feature = "bench-internals")]
13176        drop(download_phase);
13177        #[cfg(feature = "bench-internals")]
13178        let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
13179        // §5.9.5 inherits §5.1: verify the content address, reject mismatch.
13180        if blob_id_for(&bytes) != blob_id {
13181            return Err(simple(format!(
13182                "blob content address mismatch for {blob_id}"
13183            )));
13184        }
13185        #[cfg(feature = "bench-internals")]
13186        drop(validate_phase);
13187        #[cfg(feature = "bench-internals")]
13188        let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
13189        let now = self.clock_now_ms();
13190        self.conn
13191            .execute(
13192                "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
13193                rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
13194            )
13195            .map_err(|e| simple(e.to_string()))?;
13196        self.conn
13197            .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
13198            .map_err(|e| simple(e.to_string()))?;
13199        self.enforce_blob_cache_cap();
13200        #[cfg(feature = "bench-internals")]
13201        drop(insert_phase);
13202        self.get_cached_blob_bytes(&blob_id)
13203            .map_err(simple)?
13204            .ok_or_else(|| simple("blob cache write failed".to_owned()))
13205    }
13206
13207    fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
13208        #[cfg(feature = "bench-internals")]
13209        let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
13210        let mut stmt = self
13211            .conn
13212            .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
13213            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13214        let mut rows = stmt
13215            .query(rusqlite::params![blob_id])
13216            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13217        if let Some(row) = rows
13218            .next()
13219            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
13220        {
13221            let bytes: Vec<u8> = row
13222                .get(0)
13223                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13224            let byte_length: i64 = row
13225                .get(1)
13226                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13227            let media_type: Option<String> = row
13228                .get(2)
13229                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13230            return Ok(Some(FetchedBlob {
13231                blob_id: blob_id.to_owned(),
13232                byte_length,
13233                bytes,
13234                media_type,
13235            }));
13236        }
13237        Ok(None)
13238    }
13239
13240    /// §5.9.7 B4: upload every queued blob before push.
13241    fn pending_blob_uploads(&self) -> Result<Vec<crate::round::PendingUpload>, TransportError> {
13242        let pending: Vec<(String, Option<String>)> = {
13243            let mut stmt = self
13244                .conn
13245                .prepare(
13246                    "SELECT blob_id, media_type FROM (
13247                      SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
13248                      UNION ALL
13249                      SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
13250                      FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
13251                      WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
13252                    ) ORDER BY created_at_ms, blob_id",
13253                )
13254                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
13255            let rows = stmt
13256                .query_map([], |row| {
13257                    Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
13258                })
13259                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
13260            rows.collect::<Result<_, _>>()
13261                .map_err(|error| match error {
13262                    rusqlite::Error::InvalidColumnType(..)
13263                    | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
13264                        "sync.local_corrupt",
13265                        "Pending blob upload metadata is invalid",
13266                    ),
13267                    _ => TransportError::new("client.failed", error.to_string()),
13268                })?
13269        };
13270        let mut uploads = Vec::new();
13271        for (blob_id, media_type) in pending {
13272            let bytes = (|| {
13273                let stored: Option<(SqlValue, SqlValue)> = self
13274                    .conn
13275                    .query_row(
13276                        "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
13277                        rusqlite::params![blob_id],
13278                        |row| Ok((row.get(0)?, row.get(1)?)),
13279                    )
13280                    .optional()
13281                    .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
13282                let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
13283                    return Err(TransportError::new(
13284                        "sync.local_corrupt",
13285                        "Pending blob upload body is missing or corrupt",
13286                    ));
13287                };
13288                if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
13289                    return Err(TransportError::new(
13290                        "sync.local_corrupt",
13291                        "Pending blob upload body is missing or corrupt",
13292                    ));
13293                }
13294                Ok(bytes)
13295            })();
13296            let failed = bytes.is_err();
13297            uploads.push(crate::round::PendingUpload {
13298                id: blob_id,
13299                bytes,
13300                media_type,
13301            });
13302            if failed {
13303                break;
13304            }
13305        }
13306        Ok(uploads)
13307    }
13308
13309    #[cfg(test)]
13310    fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
13311        for upload in self.pending_blob_uploads()? {
13312            self.upload_one(
13313                transport,
13314                &upload.id,
13315                &upload.bytes?,
13316                upload.media_type.as_deref(),
13317            )?;
13318            self.conn
13319                .execute(
13320                    "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
13321                    rusqlite::params![upload.id],
13322                )
13323                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
13324        }
13325        Ok(())
13326    }
13327
13328    /// §5.9.3: upload one blob, preferring the presigned direct-to-storage
13329    /// grant when the transport supports it, else streaming through the direct
13330    /// host-authenticated endpoint (capability, not fallback). A `Url` grant
13331    /// PUTs direct with no host auth; on a grant PUT failure the client streams
13332    /// through the direct endpoint — a *different, host-authenticated
13333    /// capability*, not a fall-through of the grant's authority.
13334    #[cfg(test)]
13335    fn upload_one(
13336        &self,
13337        transport: &mut dyn Transport,
13338        blob_id: &str,
13339        bytes: &[u8],
13340        media_type: Option<&str>,
13341    ) -> Result<(), TransportError> {
13342        crate::round::upload_one(transport, blob_id, bytes, media_type, self.now_ms)
13343    }
13344
13345    fn visible_blob_ids_sql(&self) -> String {
13346        let mut selects = Vec::new();
13347        for table in &self.schema.tables {
13348            for column in table
13349                .columns
13350                .iter()
13351                .filter(|column| column.ty == ColumnType::BlobRef)
13352            {
13353                let identifier = quote_ident(&column.name);
13354                selects.push(format!(
13355                    "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
13356                     WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
13357                     AND json_type({identifier}, '$.blobId') = 'text'",
13358                    quote_ident(&table.name),
13359                ));
13360            }
13361        }
13362        if selects.is_empty() {
13363            "SELECT NULL AS blob_id WHERE 0".to_owned()
13364        } else {
13365            selects.join(" UNION ")
13366        }
13367    }
13368
13369    /// §5.9.7 B1 size cap. Visible rows, pending commits, and
13370    /// staged uploads pin bodies directly; no derived refcount is stored.
13371    fn enforce_blob_cache_cap(&self) {
13372        let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
13373            return;
13374        };
13375        let mut total: i64 = self
13376            .conn
13377            .query_row(
13378                "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
13379                [],
13380                |row| row.get(0),
13381            )
13382            .unwrap_or(0);
13383        if total <= max_bytes {
13384            return;
13385        }
13386        let query = format!(
13387            "SELECT blob_id, byte_length FROM _syncular_blobs
13388             WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
13389               AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
13390               AND blob_id NOT IN ({})
13391             ORDER BY created_at_ms ASC, blob_id ASC",
13392            self.visible_blob_ids_sql(),
13393        );
13394        let candidates: Vec<(String, i64)> = {
13395            let Ok(mut stmt) = self.conn.prepare(&query) else {
13396                return;
13397            };
13398            let Ok(rows) = stmt.query_map([], |row| {
13399                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
13400            }) else {
13401                return;
13402            };
13403            rows.filter_map(Result::ok).collect()
13404        };
13405        for (blob_id, byte_length) in candidates {
13406            if total <= max_bytes {
13407                break;
13408            }
13409            let _ = self.conn.execute(
13410                "DELETE FROM _syncular_blobs WHERE blob_id = ?",
13411                rusqlite::params![blob_id],
13412            );
13413            total -= byte_length;
13414        }
13415    }
13416
13417    fn delete_unreferenced_cached_blobs(&mut self) {
13418        #[cfg(feature = "bench-internals")]
13419        let _phase = self.benchmark_phases.start(Phase::BlobRetention);
13420        if !self.schema_has_blobs() {
13421            return;
13422        }
13423        self.rebuild_overlay_if_dirty();
13424        let _ = self.conn.execute(
13425            &format!(
13426                "DELETE FROM _syncular_blobs
13427                 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
13428                   AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
13429                   AND blob_id NOT IN ({})",
13430                self.visible_blob_ids_sql(),
13431            ),
13432            [],
13433        );
13434    }
13435
13436    // -- local row storage ----------------------------------------------------------
13437
13438    /// The cached per-table primary-key upsert SQL for `full_table` (see
13439    /// the `insert_sql` field: built once, reused per row).
13440    fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
13441        if let Some(sql) = self.insert_sql.borrow().get(full_table) {
13442            return sql.clone();
13443        }
13444        let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
13445        columns.push(quote_ident("_syncular_version"));
13446        let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
13447        let primary_key = quote_ident(&table.primary_key);
13448        let updates = columns
13449            .iter()
13450            .filter(|column| **column != primary_key)
13451            .map(|column| format!("{column}=excluded.{column}"))
13452            .collect::<Vec<_>>()
13453            .join(", ");
13454        let sql = format!(
13455            "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
13456            columns.join(", "),
13457            placeholders.join(", ")
13458        );
13459        self.insert_sql
13460            .borrow_mut()
13461            .insert(full_table.to_owned(), sql.clone());
13462        sql
13463    }
13464
13465    fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
13466        self.overlay_dirty.table(table_name);
13467        self.write_row(&base_table(table_name), table_name, row, version)
13468    }
13469
13470    fn write_row(
13471        &self,
13472        full_table: &str,
13473        table_name: &str,
13474        row: &Row,
13475        version: i64,
13476    ) -> Result<(), String> {
13477        #[cfg(feature = "bench-internals")]
13478        let _phase = self.benchmark_phases.start(Phase::RowWrite);
13479        let table = self
13480            .schema
13481            .table(table_name)
13482            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
13483        let sql = self.insert_row_sql(full_table, table);
13484        let mut stmt = self
13485            .conn
13486            .prepare_cached(&sql)
13487            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13488        let params = row
13489            .iter()
13490            .map(RowParam::Cell)
13491            .chain(std::iter::once(RowParam::Version(version)));
13492        stmt.execute(rusqlite::params_from_iter(params))
13493            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13494        Ok(())
13495    }
13496
13497    fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
13498        let table = self
13499            .schema
13500            .table(table_name)
13501            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
13502        self.overlay_dirty.table(table_name);
13503        let sql = format!(
13504            "DELETE FROM {} WHERE {}",
13505            base_table(table_name),
13506            row_id_predicate(table)
13507        );
13508        let mut stmt = self
13509            .conn
13510            .prepare_cached(&sql)
13511            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13512        stmt.execute(rusqlite::params![row_id])
13513            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13514        Ok(())
13515    }
13516
13517    fn has_acknowledged_rows(&self) -> bool {
13518        self.conn
13519            .query_row(
13520                "SELECT EXISTS(SELECT 1 FROM _syncular_acked_rows)",
13521                [],
13522                |row| row.get(0),
13523            )
13524            .expect("read acknowledged intent")
13525    }
13526
13527    fn acknowledged_operations(
13528        &self,
13529        rows: Option<&HashSet<(&str, &str)>>,
13530    ) -> Result<Vec<OutboxOp>, String> {
13531        let keys = rows.map(|rows| {
13532            serde_json::to_string(
13533                &rows
13534                    .iter()
13535                    .map(|(table, id)| serde_json::to_string(&(table, id)).expect("row key"))
13536                    .collect::<Vec<_>>(),
13537            )
13538            .expect("row keys")
13539        });
13540        let mut statement = self
13541            .conn
13542            .prepare("SELECT op_json,intent_json FROM _syncular_acked_rows WHERE ?1 IS NULL OR json_array(tbl,id) IN(SELECT value FROM json_each(?1)) ORDER BY commit_seq,idx")
13543            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13544        let rows = statement
13545            .query_map([keys], |row| {
13546                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
13547            })
13548            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13549        rows.map(|row| {
13550            let (op, intent) =
13551                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13552            let op: CommitOperation =
13553                serde_json::from_str(&op).map_err(|_| "sync.local_corrupt".to_owned())?;
13554            let table = self
13555                .schema
13556                .table(&op.table)
13557                .ok_or_else(|| "sync.unknown_table".to_owned())?;
13558            let values = if op.op == "upsert" && self.visible_row(table, &op.row_id).is_none() {
13559                Some(serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?)
13560            } else {
13561                op.values
13562            };
13563            Ok(OutboxOp {
13564                table: op.table,
13565                row_id: op.row_id,
13566                upsert: op.op == "upsert",
13567                base_version: op.base_version,
13568                values,
13569            })
13570        })
13571        .collect()
13572    }
13573
13574    fn drop_acknowledged_rows(
13575        &self,
13576        matches: impl Fn(&str, &Map<String, Value>) -> bool,
13577    ) -> Result<Vec<OutboxCommit>, String> {
13578        let mut statement = self
13579            .conn
13580            .prepare("SELECT commit_id,tbl,intent_json,id FROM _syncular_acked_rows")
13581            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13582        let rows = statement
13583            .query_map([], |row| {
13584                Ok((
13585                    row.get::<_, String>(0)?,
13586                    row.get::<_, String>(1)?,
13587                    row.get::<_, String>(2)?,
13588                    row.get::<_, String>(3)?,
13589                ))
13590            })
13591            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13592        let mut doomed = HashSet::new();
13593        for row in rows {
13594            let (id, table, intent, row_id) =
13595                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13596            let values =
13597                serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?;
13598            let base_matches = self
13599                .schema
13600                .table(&table)
13601                .and_then(|schema| self.stored_row(schema, &row_id, true))
13602                .is_some_and(|(base, _)| {
13603                    let schema = self.schema.table(&table).expect("acknowledged table");
13604                    let values = schema
13605                        .columns
13606                        .iter()
13607                        .zip(base.iter())
13608                        .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
13609                        .collect();
13610                    matches(&table, &values)
13611                });
13612            if matches(&table, &values) || base_matches {
13613                doomed.insert(id);
13614            }
13615        }
13616        let mut dropped = Vec::new();
13617        for id in doomed {
13618            let mut statement = self
13619                .conn
13620                .prepare("SELECT op_json FROM _syncular_acked_rows WHERE commit_id=?1 ORDER BY idx")
13621                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13622            let operations = statement
13623                .query_map([&id], |row| row.get::<_, String>(0))
13624                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13625            let mut ops = Vec::new();
13626            for operation in operations {
13627                let json = operation
13628                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13629                let op: CommitOperation =
13630                    serde_json::from_str(&json).map_err(|_| "sync.local_corrupt".to_owned())?;
13631                self.overlay_dirty.table(&op.table);
13632                ops.push(OutboxOp {
13633                    table: op.table,
13634                    row_id: op.row_id,
13635                    upsert: op.op == "upsert",
13636                    base_version: op.base_version,
13637                    values: op.values,
13638                });
13639            }
13640            dropped.push(OutboxCommit {
13641                client_commit_id: id.clone(),
13642                ops,
13643            });
13644            self.conn
13645                .execute("DELETE FROM _syncular_acked_rows WHERE commit_id=?1", [id])
13646                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13647        }
13648        Ok(dropped)
13649    }
13650
13651    /// [`Self::rebuild_overlay`], skipped when neither the base tables nor
13652    /// the outbox changed since the last rebuild (the no-op sync round).
13653    fn rebuild_overlay_if_dirty(&mut self) {
13654        if self.overlay_dirty.get() {
13655            self.rebuild_overlay();
13656        }
13657    }
13658
13659    /// §7.1: local reads see outbox state applied optimistically — rebuild
13660    /// every visible table as (base server state) + (pending outbox replay
13661    /// on top). Optimistic rows carry version `-1`.
13662    fn rebuild_overlay(&mut self) {
13663        #[cfg(feature = "bench-internals")]
13664        let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
13665        #[cfg(test)]
13666        self.overlay_rebuild_count
13667            .set(self.overlay_rebuild_count.get() + 1);
13668        let dirty = self.overlay_dirty.snapshot();
13669        let tables: Vec<_> = self
13670            .schema
13671            .tables
13672            .iter()
13673            .filter(|table| {
13674                dirty
13675                    .as_ref()
13676                    .is_none_or(|names| names.is_empty() || names.contains(&table.name))
13677            })
13678            .cloned()
13679            .collect();
13680        let names: BTreeSet<_> = tables.iter().map(|table| table.name.as_str()).collect();
13681        self.exec("SAVEPOINT syncular_overlay");
13682        for table in &tables {
13683            for index in &table.fts_indexes {
13684                let _ = self.drop_fts_triggers(index);
13685            }
13686            let visible = visible_table(&table.name);
13687            let base = base_table(&table.name);
13688            self.exec(&format!("DELETE FROM {visible}"));
13689            self.exec(&format!("INSERT INTO {visible} SELECT * FROM {base}"));
13690        }
13691        let acknowledged = self
13692            .acknowledged_operations(None)
13693            .expect("decode acknowledged intent");
13694        self.apply_outbox_ops(
13695            acknowledged
13696                .iter()
13697                .filter(|op| names.contains(op.table.as_str())),
13698        );
13699        for (commit, initial) in &self.failed_commits {
13700            for operation in &commit.ops {
13701                if !names.contains(operation.table.as_str()) || !operation.upsert {
13702                    continue;
13703                }
13704                if let Some(table) = self.schema.table(&operation.table) {
13705                    if self.visible_row(table, &operation.row_id).is_none()
13706                        && operation.values.as_ref().is_some_and(|values| {
13707                            table
13708                                .columns
13709                                .iter()
13710                                .all(|column| values.contains_key(&column.name))
13711                        })
13712                    {
13713                        let key = serde_json::to_string(&(
13714                            operation.table.clone(),
13715                            operation.row_id.clone(),
13716                        ))
13717                        .expect("row key serialization");
13718                        if let Some(Value::Object(values)) = initial.get(&key) {
13719                            self.apply_outbox_ops(&[OutboxOp {
13720                                values: Some(values.clone()),
13721                                ..operation.clone()
13722                            }]);
13723                        }
13724                    }
13725                }
13726            }
13727            self.apply_outbox_ops(
13728                commit
13729                    .ops
13730                    .iter()
13731                    .filter(|op| names.contains(op.table.as_str())),
13732            );
13733        }
13734        for commit in &self.outbox {
13735            self.apply_outbox_ops(
13736                commit
13737                    .ops
13738                    .iter()
13739                    .filter(|op| names.contains(op.table.as_str())),
13740            );
13741        }
13742        for table in &tables {
13743            for index in &table.fts_indexes {
13744                let _ = self.rebuild_fts_projection(table, index);
13745                let _ = self.create_fts_triggers(table, index);
13746            }
13747        }
13748        self.exec("RELEASE syncular_overlay");
13749        self.overlay_dirty.set(false);
13750    }
13751
13752    /// §7.1 replay: every operation is applied over the current visible
13753    /// state. An operation that no longer applies (a server row now holds its
13754    /// unique value) stays pending and invisible until a later replay admits
13755    /// it or the server answers its push.
13756    fn apply_outbox_ops<'a>(&self, ops: impl IntoIterator<Item = &'a OutboxOp>) {
13757        #[cfg(feature = "bench-internals")]
13758        let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
13759        for op in ops {
13760            #[cfg(feature = "bench-internals")]
13761            if let Some(phase) = &mut phase {
13762                phase.unit();
13763            }
13764            if op.upsert {
13765                if let (Some(table), Some(values)) = (
13766                    self.schema
13767                        .table(&op.table)
13768                        .filter(|table| table.indexes.iter().any(|index| index.unique)),
13769                    op.values.as_ref(),
13770                ) {
13771                    let mut intended: Map<String, Value> = self
13772                        .visible_row(table, &op.row_id)
13773                        .map(|(row, _)| {
13774                            table
13775                                .columns
13776                                .iter()
13777                                .zip(&row)
13778                                .map(|(column, value)| {
13779                                    (column.name.clone(), column_value_to_json(value))
13780                                })
13781                                .collect()
13782                        })
13783                        .unwrap_or_default();
13784                    intended.extend(values.clone());
13785                    if !self
13786                        .unique_conflicts(table, &intended, false)
13787                        .expect("overlay unique-key read failed")
13788                        .is_empty()
13789                    {
13790                        continue;
13791                    }
13792                }
13793            }
13794            let _ = self.apply_outbox_op(op);
13795        }
13796    }
13797
13798    fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
13799        let Some(table) = self.schema.table(&op.table) else {
13800            return Ok(());
13801        };
13802        let visible = visible_table(&table.name);
13803        if !op.upsert {
13804            let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
13805            self.conn
13806                .prepare_cached(&sql)
13807                .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
13808                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13809            return Ok(());
13810        }
13811        let Some(values) = op.values.as_ref() else {
13812            return Ok(());
13813        };
13814        // §7.1: the overlay applies the operation's PRESENT columns
13815        // over the current local row. A partial operation over an
13816        // absent local row leaves it absent — the server answers it
13817        // with sync.row_deleted or sync.row_missing (§6.2).
13818        let full = table
13819            .columns
13820            .iter()
13821            .all(|column| values.contains_key(&column.name));
13822        let local = self.visible_row(table, &op.row_id);
13823        if local.is_none() && !full {
13824            return Ok(());
13825        }
13826        let mut row: Row = Vec::with_capacity(table.columns.len());
13827        for (index, column) in table.columns.iter().enumerate() {
13828            let incoming = match values.get(&column.name) {
13829                Some(raw) => json_to_column_value(column, Some(raw)),
13830                None => Ok(local
13831                    .as_ref()
13832                    .and_then(|(values, _)| values.get(index).cloned())
13833                    .flatten()),
13834            };
13835            match incoming {
13836                Ok(value) => row.push(value),
13837                Err(_) => return Ok(()),
13838            }
13839        }
13840        let version = local.as_ref().map_or(-1, |(_, version)| *version);
13841        self.write_row(&visible, &table.name, &row, version)
13842            .map_err(|error| {
13843                if error.starts_with("UNIQUE constraint failed:") {
13844                    "sync.constraint_violation: local write violates a unique constraint".to_owned()
13845                } else {
13846                    error
13847                }
13848            })
13849    }
13850
13851    /// The visible (optimistic) row's values plus version, or `None` when the
13852    /// row is absent from the overlay.
13853    fn visible_row(&self, table: &TableSchema, row_id: &str) -> Option<(Row, i64)> {
13854        self.stored_row(table, row_id, false)
13855    }
13856
13857    fn stored_row(&self, table: &TableSchema, row_id: &str, base: bool) -> Option<(Row, i64)> {
13858        let sql = format!(
13859            "SELECT * FROM {} WHERE {}",
13860            if base {
13861                base_table(&table.name)
13862            } else {
13863                visible_table(&table.name)
13864            },
13865            row_id_predicate(table)
13866        );
13867        let mut stmt = self.conn.prepare_cached(&sql).ok()?;
13868        let mut rows = stmt.query(rusqlite::params![row_id]).ok()?;
13869        let row = rows.next().ok()??;
13870        let mut values = Vec::with_capacity(table.columns.len());
13871        for (index, column) in table.columns.iter().enumerate() {
13872            let raw = row.get_ref(index).ok()?;
13873            let json = sql_ref_to_json(column, raw);
13874            values.push(json_to_column_value(column, Some(&json)).ok()?);
13875        }
13876        let version: i64 = row.get(table.columns.len()).ok()?;
13877        Some((values, version))
13878    }
13879
13880    fn exec(&self, sql: &str) {
13881        let _ = self.conn.execute_batch(sql);
13882    }
13883
13884    // -- realtime (§8) ---------------------------------------------------------------
13885
13886    /// §8.8: the configured realtime policy.
13887    #[must_use]
13888    pub fn realtime_policy(&self) -> RealtimePolicy {
13889        self.realtime_policy
13890    }
13891
13892    /// §8.8: the explicit realtime availability state.
13893    #[must_use]
13894    pub fn realtime_state(&self) -> RealtimeState {
13895        self.realtime_state
13896    }
13897
13898    /// §8.8: set the realtime policy. Refused while the socket is connected:
13899    /// a policy change never silently re-routes an ownership state.
13900    pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
13901        if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
13902            return Err(
13903                "sync.invalid_request: disconnect realtime before changing the realtime policy"
13904                    .to_owned(),
13905            );
13906        }
13907        self.realtime_policy = policy;
13908        match policy {
13909            RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
13910            RealtimePolicy::Required | RealtimePolicy::Optional
13911                if self.realtime_state == RealtimeState::Disabled =>
13912            {
13913                self.set_realtime_state(RealtimeState::Disconnected, None);
13914            }
13915            RealtimePolicy::Required | RealtimePolicy::Optional => {}
13916        }
13917        Ok(())
13918    }
13919
13920    /// Record an availability transition, keeping the reason code bounded and
13921    /// code-like (§7.6) and clearing it where it cannot apply.
13922    fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
13923        self.realtime_state = state;
13924        self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
13925        if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
13926            self.realtime_reason_code = None;
13927            self.realtime_retry_delay_ms = None;
13928        }
13929    }
13930
13931    #[must_use]
13932    fn realtime_connected(&self) -> bool {
13933        self.realtime_state == RealtimeState::Connected
13934    }
13935
13936    pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
13937        if !self.transport_enabled {
13938            return Err("sync.offline: transport gate is closed".into());
13939        }
13940        if self.realtime_policy == RealtimePolicy::Off {
13941            return Err(
13942                "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
13943            );
13944        }
13945        if self.realtime_connected() {
13946            return Ok(());
13947        }
13948        self.realtime_state = RealtimeState::Connecting;
13949        match transport.realtime_connect_for_client(&self.client_id) {
13950            Ok(()) => {
13951                self.set_realtime_state(RealtimeState::Connected, None);
13952                Ok(())
13953            }
13954            Err(error) => {
13955                self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
13956                Err(format!("{}: {}", error.code, error.message))
13957            }
13958        }
13959    }
13960
13961    pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
13962        self.cancel_sync_round();
13963        // A deliberately disconnected or policy-disabled client has nothing
13964        // to release; a `lost` socket still does (the host owns the handle).
13965        if self.realtime_state == RealtimeState::Disconnected
13966            || self.realtime_state == RealtimeState::Disabled
13967        {
13968            return;
13969        }
13970        let _ = transport.realtime_close();
13971        self.set_realtime_state(
13972            if self.realtime_policy == RealtimePolicy::Off {
13973                RealtimeState::Disabled
13974            } else {
13975                RealtimeState::Disconnected
13976            },
13977            None,
13978        );
13979        self.presence.clear(); // §8.6.1: presence is per-connection
13980    }
13981
13982    /// §8.6.2: publish (or clear, `doc: None`) this client's presence
13983    /// document for `scope_key`. Requires a live socket; the document is
13984    /// ephemeral (lost on disconnect). Authorization is the connection's
13985    /// registration (§8.6.3) — an unheld key is rejected loudly by the
13986    /// server with `presence.forbidden`.
13987    pub fn set_presence(
13988        &mut self,
13989        transport: &mut dyn Transport,
13990        scope_key: &str,
13991        doc: Option<&Value>,
13992    ) -> Result<(), String> {
13993        if !self.transport_enabled {
13994            return Err("sync.offline: transport gate is closed".into());
13995        }
13996        if !self.realtime_connected() {
13997            return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
13998        }
13999        let text = encode_presence_publish(scope_key, doc);
14000        transport
14001            .realtime_send(&text)
14002            .map_err(|e| format!("{}: {}", e.code, e.message))
14003    }
14004
14005    /// §8.6: the peers currently present on a scope key (ephemeral).
14006    pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
14007        self.presence
14008            .get(scope_key)
14009            .map(|peers| peers.values().cloned().collect())
14010            .unwrap_or_default()
14011    }
14012
14013    /// §8.6 apply an inbound presence fanout to the local map.
14014    fn apply_presence(
14015        &mut self,
14016        scope_key: String,
14017        kind: Option<PresenceKind>,
14018        actor_id: Option<String>,
14019        client_id: Option<String>,
14020        doc: Option<Value>,
14021        error: Option<String>,
14022    ) {
14023        // The publisher-directed error variant is out-of-band; nothing to
14024        // record in the peer map.
14025        if error.is_some() {
14026            return;
14027        }
14028        let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
14029            return;
14030        };
14031        let peer_key = format!("{actor_id} {client_id}");
14032        match kind {
14033            PresenceKind::Leave => {
14034                if let Some(peers) = self.presence.get_mut(&scope_key) {
14035                    peers.remove(&peer_key);
14036                    if peers.is_empty() {
14037                        self.presence.remove(&scope_key);
14038                    }
14039                }
14040            }
14041            _ => {
14042                let doc = match doc {
14043                    Some(Value::Object(_)) => doc.unwrap(),
14044                    _ => return,
14045                };
14046                self.presence.entry(scope_key).or_default().insert(
14047                    peer_key,
14048                    PresencePeer {
14049                        actor_id,
14050                        client_id,
14051                        doc,
14052                    },
14053                );
14054            }
14055        }
14056    }
14057
14058    /// Inbound JSON control message (§8.1). Unknown events are tolerated.
14059    pub fn on_realtime_text(&mut self, text: &str) {
14060        match parse_control(text) {
14061            Ok(ControlMessage::Hello { requires_sync, .. }) => {
14062                if requires_sync {
14063                    // §8.1: pull before trusting the socket for continuity.
14064                    self.set_sync_needed(true, true);
14065                }
14066            }
14067            Ok(ControlMessage::Presence {
14068                scope_key,
14069                kind,
14070                actor_id,
14071                client_id,
14072                doc,
14073                error,
14074                ..
14075            }) => {
14076                self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
14077            }
14078            Ok(ControlMessage::Wake { .. }) => {
14079                // §8.3: any wake-up means "run a pull soon", never data.
14080                self.set_sync_needed(true, true);
14081            }
14082            _ => {}
14083        }
14084    }
14085
14086    /// Inbound binary delta: a complete SSP2 response (§8.2), applied like
14087    /// a pull response per section; an unapplied delta is a wake-up.
14088    /// Mailbox hosts send durable acknowledgements on their I/O executor.
14089    pub fn on_realtime_binary_queued(&mut self, bytes: &[u8]) -> Vec<String> {
14090        let mut transport = crate::round::DownloadResults::default();
14091        self.on_realtime_binary(&mut transport, bytes);
14092        transport.controls
14093    }
14094
14095    pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
14096        if self.stopped || !self.transport_enabled {
14097            return;
14098        }
14099        #[cfg(feature = "bench-internals")]
14100        let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
14101        let message = match decode_message(bytes) {
14102            Ok(m) if m.msg_kind == MsgKind::Response => m,
14103            _ => {
14104                self.set_sync_needed(true, true);
14105                return;
14106            }
14107        };
14108        #[cfg(feature = "bench-internals")]
14109        drop(decode_phase);
14110        let mut frames = message.frames.into_iter();
14111        let mut applied_cursor: Option<i64> = None;
14112        let mut any_covered = false;
14113        let mut dropped = false;
14114        while let Some(frame) = frames.next() {
14115            let Frame::SubStart {
14116                id,
14117                status,
14118                effective_scopes,
14119                ..
14120            } = frame
14121            else {
14122                continue;
14123            };
14124            let mut body = Vec::new();
14125            let mut next_cursor: Option<i64> = None;
14126            for inner in frames.by_ref() {
14127                match inner {
14128                    Frame::SubEnd {
14129                        next_cursor: nc, ..
14130                    } => {
14131                        next_cursor = Some(nc);
14132                        break;
14133                    }
14134                    Frame::Unknown { .. } => {}
14135                    other => body.push(other),
14136                }
14137            }
14138            let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
14139                dropped = true;
14140                continue;
14141            };
14142            let sub = &self.subs[sub_index];
14143            // §8.2: only locally active, not mid-bootstrap subscriptions
14144            // apply; skipped sections are repaired by the next pull.
14145            if status != SubStatus::Active
14146                || sub.state != SubState::Active
14147                || sub.bootstrap_state.is_some()
14148                || !sub.synced_once
14149            {
14150                dropped = true;
14151                continue;
14152            }
14153            if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
14154                // Idempotent redelivery of an already-covered window.
14155                any_covered = true;
14156                continue;
14157            }
14158            let previous_effective = self.subs[sub_index].effective.clone();
14159            let previous_cursor = self.subs[sub_index].cursor;
14160            self.subs[sub_index].effective = Some(effective_scopes);
14161            let mut failed = false;
14162            for inner in body {
14163                match inner {
14164                    Frame::Commit {
14165                        tables,
14166                        changes,
14167                        commit_seq,
14168                        ..
14169                    } => {
14170                        if self
14171                            .apply_commit_frame(&tables, &changes, Some(commit_seq))
14172                            .is_err()
14173                        {
14174                            failed = true;
14175                            break;
14176                        }
14177                    }
14178                    _ => {
14179                        failed = true;
14180                        break;
14181                    }
14182                }
14183            }
14184            let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
14185                self.apply_sub_end(sub_index, cursor, None)
14186                    .ok()
14187                    .map(|()| cursor)
14188            });
14189            let Some(next_cursor) = committed else {
14190                self.subs[sub_index].effective = previous_effective;
14191                self.subs[sub_index].cursor = previous_cursor;
14192                dropped = true;
14193                continue;
14194            };
14195            applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
14196        }
14197        if let Some(cursor) = applied_cursor {
14198            // §8.2 ack point: the highest applied SUB_END.nextCursor.
14199            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
14200            let _ = transport.realtime_send(&ack);
14201        } else if !any_covered || dropped {
14202            // §8.2: a delta not applied at all is treated as a wake-up.
14203            self.set_sync_needed(true, true);
14204        }
14205    }
14206
14207    /// §8.2 ack point after an HTTP pull on a live connection: the minimum
14208    /// cursor across active, non-bootstrapping subscriptions that have
14209    /// synced at least once. No such subscription, no ack.
14210    fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
14211        if !self.realtime_connected() {
14212            return;
14213        }
14214        let floor = self
14215            .subs
14216            .iter()
14217            .filter(|s| {
14218                s.state == SubState::Active
14219                    && s.bootstrap_state.is_none()
14220                    && s.synced_once
14221                    && s.cursor >= 0
14222            })
14223            .map(|s| s.cursor)
14224            .min();
14225        if let Some(cursor) = floor {
14226            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
14227            let _ = transport.realtime_send(&ack);
14228        }
14229    }
14230}
14231
14232#[cfg(test)]
14233mod previous_version_wiring_tests {
14234    use super::*;
14235    use serde_json::{json, Value};
14236
14237    fn previous_version_schema(version: i32) -> Value {
14238        json!({
14239            "version": version,
14240            "tables": [{
14241                "name": "tasks",
14242                "primaryKey": "id",
14243                "columns": [
14244                    { "name": "id", "type": "string", "nullable": false },
14245                    { "name": "note", "type": "string", "nullable": true }
14246                ],
14247                "scopes": []
14248            }]
14249        })
14250    }
14251
14252    fn previous_version_limits() -> crate::api::ClientLimits {
14253        crate::api::ClientLimits {
14254            previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
14255                enabled: true,
14256                ..Default::default()
14257            }),
14258            ..Default::default()
14259        }
14260    }
14261
14262    /// The configuration the live client carries, for a direct capture call.
14263    fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
14264        previous_version_limits()
14265            .previous_version_context
14266            .expect("configured")
14267    }
14268
14269    /// Capture the container the way a bump would, so a read test starts from a
14270    /// container this client's own state will then decide about.
14271    fn recapture_previous_version(client: &SyncClient) {
14272        let replica_path = client
14273            .previous_version_replica_path()
14274            .expect("file-backed replica");
14275        let outcome = crate::previous_version::capture_previous_version_from_replica(
14276            &client.conn,
14277            &replica_path,
14278            2,
14279            2,
14280            &previous_version_config(),
14281            1,
14282        )
14283        .expect("capture");
14284        assert!(matches!(
14285            outcome,
14286            crate::previous_version::CaptureOutcome::Captured(_)
14287        ));
14288    }
14289
14290    fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
14291        Subscription {
14292            id: "wiring-sub".to_owned(),
14293            table: "tasks".to_owned(),
14294            requested: Vec::new(),
14295            params: None,
14296            cursor,
14297            bootstrap_state: bootstrap_state.map(str::to_owned),
14298            state,
14299            reason_code: None,
14300            effective: None,
14301            synced_once: false,
14302        }
14303    }
14304
14305    #[test]
14306    fn previous_version_read_reasons_come_from_live_client_state() {
14307        use crate::previous_version::{
14308            PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
14309        };
14310        let path = std::env::temp_dir()
14311            .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
14312            .to_string_lossy()
14313            .into_owned();
14314        let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
14315        {
14316            let mut setup = SyncClient::open_path(
14317                "wiring".to_owned(),
14318                &previous_version_schema(1),
14319                previous_version_limits(),
14320                &path,
14321            )
14322            .expect("v1 install");
14323            setup
14324                .mutate(vec![Mutation::Upsert {
14325                    table: "tasks".to_owned(),
14326                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
14327                    base_version: None,
14328                }])
14329                .expect("seed a row");
14330        }
14331        let mut client = SyncClient::open_path(
14332            "wiring".to_owned(),
14333            &previous_version_schema(2),
14334            previous_version_limits(),
14335            &path,
14336        )
14337        .expect("v2 bump captures");
14338        assert!(std::path::Path::new(&container).exists());
14339        let spec = PreviousVersionReadSpec {
14340            table: "tasks".to_owned(),
14341            ..Default::default()
14342        };
14343
14344        // §7.3.5 lease error ⇒ lease-inactive, and the read discards.
14345        recapture_previous_version(&client);
14346        client.lease_state = Some(LeaseState {
14347            lease_id: None,
14348            expires_at_ms: None,
14349            error_code: Some("lease.expired".to_owned()),
14350        });
14351        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
14352        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
14353        assert!(!snapshot.available);
14354        assert!(!std::path::Path::new(&container).exists());
14355
14356        // An expired lease uses the client clock (injected, no sleeps).
14357        recapture_previous_version(&client);
14358        client.set_now_ms(1_000);
14359        client.lease_state = Some(LeaseState {
14360            lease_id: Some("lease".to_owned()),
14361            expires_at_ms: Some(1_000),
14362            error_code: None,
14363        });
14364        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
14365        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
14366        assert!(!std::path::Path::new(&container).exists());
14367        client.lease_state = None;
14368
14369        // A revoked subscription ⇒ scope-revoked.
14370        recapture_previous_version(&client);
14371        client.subs.push(subscription(SubState::Revoked, 0, None));
14372        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
14373        assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
14374        assert!(!std::path::Path::new(&container).exists());
14375        client.subs.clear();
14376
14377        // Coverage completion: every ACTIVE subscription has a cursor and no
14378        // resume token — a still-resuming subscription is not completion.
14379        recapture_previous_version(&client);
14380        client
14381            .subs
14382            .push(subscription(SubState::Active, 0, Some("resume")));
14383        assert!(!client.previous_version_coverage_complete());
14384        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
14385        assert!(snapshot.available);
14386        assert_eq!(snapshot.previous_version, Some(2));
14387        assert_eq!(snapshot.rows.len(), 1);
14388
14389        recapture_previous_version(&client);
14390        client.subs[0].bootstrap_state = None;
14391        assert!(client.previous_version_coverage_complete());
14392        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
14393        assert_eq!(
14394            snapshot.reason,
14395            Some(PreviousVersionReason::CoverageComplete)
14396        );
14397        assert!(!std::path::Path::new(&container).exists());
14398        client.subs.clear();
14399
14400        // With no active subscription at all, coverage is NOT complete.
14401        assert!(!client.previous_version_coverage_complete());
14402
14403        drop(client);
14404        let _ = std::fs::remove_file(&path);
14405    }
14406
14407    /// RFC 0005 D7 lifetime drops. The read surface already proves the reasons
14408    /// come from live state; this proves the OTHER caller —
14409    /// `previous_version_lifetime_check`, the once-per-sync-round trigger —
14410    /// actually removes the container for lease-inactivity, coverage
14411    /// completion and scope revocation, and leaves it alone while the
14412    /// replacement bootstrap is still resuming.
14413    #[test]
14414    fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
14415        let path = std::env::temp_dir()
14416            .join(format!(
14417                "syncular-prev-lifetime-{}.db",
14418                uuid::Uuid::new_v4()
14419            ))
14420            .to_string_lossy()
14421            .into_owned();
14422        let container = format!(
14423            "{path}{}",
14424            crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
14425        );
14426        let exists = || std::path::Path::new(&container).exists();
14427        {
14428            let mut setup = SyncClient::open_path(
14429                "lifetime".to_owned(),
14430                &previous_version_schema(1),
14431                previous_version_limits(),
14432                &path,
14433            )
14434            .expect("v1 install");
14435            setup
14436                .mutate(vec![Mutation::Upsert {
14437                    table: "tasks".to_owned(),
14438                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
14439                    base_version: None,
14440                }])
14441                .expect("seed a row");
14442        }
14443        let mut client = SyncClient::open_path(
14444            "lifetime".to_owned(),
14445            &previous_version_schema(2),
14446            previous_version_limits(),
14447            &path,
14448        )
14449        .expect("v2 bump captures");
14450        // Pin the clock so the 24h TTL never fires in this test (the TTL has
14451        // its own coverage); the capture record's createdAtMs is 1.
14452        client.set_now_ms(1_000);
14453        assert!(exists());
14454
14455        // §7.3.5 lease error ⇒ the lifetime check drops the container.
14456        client.lease_state = Some(LeaseState {
14457            lease_id: None,
14458            expires_at_ms: None,
14459            error_code: Some("lease.expired".to_owned()),
14460        });
14461        client.previous_version_lifetime_check();
14462        assert!(!exists());
14463        client.lease_state = None;
14464
14465        // §7.4.5 coverage completion (every active sub has a cursor and no
14466        // resume token) ⇒ drop.
14467        recapture_previous_version(&client);
14468        client.subs.push(subscription(SubState::Active, 0, None));
14469        client.previous_version_lifetime_check();
14470        assert!(!exists());
14471        client.subs.clear();
14472
14473        // §3.3 scope revocation ⇒ drop.
14474        recapture_previous_version(&client);
14475        client.subs.push(subscription(SubState::Revoked, 0, None));
14476        client.previous_version_lifetime_check();
14477        assert!(!exists());
14478        client.subs.clear();
14479
14480        // Negative control: a still-resuming active subscription is NOT
14481        // coverage completion, so the container survives the same call.
14482        recapture_previous_version(&client);
14483        client
14484            .subs
14485            .push(subscription(SubState::Active, 0, Some("resume")));
14486        client.previous_version_lifetime_check();
14487        assert!(exists());
14488        client.subs.clear();
14489
14490        drop(client);
14491        let _ = std::fs::remove_file(&path);
14492        let _ = std::fs::remove_file(&container);
14493        let _ = std::fs::remove_file(format!("{container}-wal"));
14494        let _ = std::fs::remove_file(format!("{container}-shm"));
14495    }
14496}