Skip to main content

syncular_client/
client.rs

1//! The Syncular v2 Rust client core (SPEC.md client-behavior contract):
2//! rusqlite local storage, §3.2/§3.3 effective-scope persistence + purge,
3//! §4 pull/cursor/bootstrap (§4.7 resume, §5.6 segment application), §6
4//! push with outbox order, §7 optimistic apply / rollback / replay-on-top,
5//! §2.3 clientCommitId idempotency, §8 realtime client rules, §10 errors.
6//!
7//! Built from `SPEC.md` and the committed `ssp2` codec alone — no
8//! reference to the v1 Rust tree or the v2 TypeScript client.
9
10use crate::{ProgressObserver, ProgressPhase, ProgressState};
11#[cfg(test)]
12use ssp2::encode_message;
13#[cfg(test)]
14use std::cell::Cell;
15use std::cell::RefCell;
16use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
17
18use rusqlite::types::{ToSqlOutput, Value as SqlValue, ValueRef};
19use rusqlite::{Connection, OpenFlags, OptionalExtension};
20use serde::{Deserialize, Serialize};
21use serde_json::{Map, Value};
22use sha2::{Digest, Sha256};
23use ssp2::decode::WIRE_VERSION;
24use ssp2::model::{Frame, MediaType, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
25use ssp2::primitives::RawJson;
26use ssp2::segment::{decode_rows_segment, Column, ColumnType, ColumnValue, Row, RowsSegment};
27use ssp2::{decode_message, encode_presence_publish, parse_control, ControlMessage, PresenceKind};
28
29use crate::api::{
30    ClientChangeBatch, ClientDiagnosticsHost, ClientDiagnosticsLease, ClientDiagnosticsReplica,
31    ClientDiagnosticsRequest, ClientDiagnosticsSchema, ClientDiagnosticsSnapshot,
32    ClientDiagnosticsStorage, ClientLimits, CommandEffects, CommitOperation,
33    CommitOperationOutcome, CommitOutcome, CommitOutcomeQuery, CommitOutcomeResolution,
34    CommitOutcomeStatus, ConflictRecord, CoverageSnapshot, DiagnosticLastChange,
35    DiagnosticLastRound, DiagnosticQueryFailure, DiagnosticRoundCounters, DiagnosticSubscription,
36    FetchedBlob, LeaseState, LocalDataPurgeInput, LocalDataPurgeResult, LocalDataPurgeTarget,
37    LocalDataRebootstrapInput, LocalDataRebootstrapResult, Mutation, PresencePeer,
38    PreviousVersionStatus, QueryOwner, QueryReadFailure, QueryRow, QuerySnapshot, QueryValue,
39    RejectionDetails, RejectionRecord, ResolveCommitOutcomeInput, RetainedCommitRow,
40    RetainedUniqueConflict, RowState, SchemaFloor, SubscriptionStateView, SyncIntent, SyncOutcome,
41    SyncReport, SyncStatusSnapshot, TableChange, WindowBase, WindowChange, WindowCoverage,
42    WindowState, WindowUnitRef, CLIENT_DIAGNOSTICS_VERSION, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
43    MAX_DIAGNOSTIC_QUERY_FAILURES,
44};
45use crate::api::{RealtimePolicy, RealtimeState, REALTIME_UNAVAILABLE_CODE};
46#[cfg(feature = "bench-internals")]
47use crate::bench::{Phase, Recorder};
48use crate::previous_version::{
49    capture_previous_version_from_replica, first_incompatibility,
50    reconcile_previous_version_at_boot, set_local_schema_descriptor,
51    sweep_previous_version_container, PendingCommitAudit, PreviousVersionContextConfig,
52    PreviousVersionLifecycle, PreviousVersionReadSpec, PreviousVersionSnapshot,
53};
54use crate::schema::{parse_schema_json, ClientSchema, FtsIndexSchema, TableSchema};
55use crate::transport::{BlobDownload, SegmentRequest, Transport, TransportError};
56use crate::values::{
57    bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes,
58    encode_sparse_row_json, full_row_values, json_to_column_value, json_to_scope_map,
59    normalize_values_casing, render_row_id_json, scope_map_to_json, sort_scope_map,
60};
61
62/// §4.2 default: rows and SQLite images, both applied in committed chunks.
63/// Bit 3 (signed URLs, §5.4) is
64/// added per transport capability at request-build time.
65const DEFAULT_ACCEPT: u8 = 0b0111;
66const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
67const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
68const ACCEPT_SQLITE: u8 = 1 << 2;
69const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
70const MAX_DIAGNOSTIC_DOMAINS: usize = 256;
71
72/// §7.4.1 persisted local schema-version marker (`_syncular_meta` key).
73pub(crate) const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
74const LOCAL_REVISION_KEY: &str = "localRevision";
75const CLIENT_ID_KEY: &str = "clientId";
76const LEASE_STATE_KEY: &str = "leaseState";
77const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
78const LOG_EPOCH_KEY: &str = "logEpoch";
79/// Persisted fail-closed quarantine marker: present while a security preflight
80/// is pending and `activateSecurity` has yet to run. Storing it in the replica
81/// keeps the gate with the data it protects, so a rebuilt host handle reopening
82/// the same file returns in preflight even though its in-memory state is fresh.
83const SECURITY_PREFLIGHT_PENDING_KEY: &str = "securityPreflightPending";
84const LOCAL_REBOOTSTRAP_RECEIPT_VERSION: u8 = 2;
85const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
86/// §7.4.4 client-local code: a pending outbox commit cannot re-encode under
87/// the new schema after a bump. Never a wire code (§10.3).
88const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
89/// §5.11 client-local code: an encode at the push seam resolved no usable key
90/// id or named an unknown key. Never a wire code (§10.3).
91const ENCRYPT_FAILED_CODE: &str = "client.encrypt_failed";
92
93#[derive(Debug, Serialize, Deserialize)]
94#[serde(rename_all = "camelCase", deny_unknown_fields)]
95struct PersistedLocalDataRebootstrapReceipt {
96    version: u8,
97    retained_commits: u64,
98    reset_subscriptions: u64,
99}
100
101fn invalid_local_rebootstrap_receipt() -> String {
102    "sync.local_corrupt: persisted local rebootstrap receipt is invalid".to_owned()
103}
104
105fn encode_local_rebootstrap_receipt(
106    retained_commits: usize,
107    reset_subscriptions: usize,
108) -> Result<String, String> {
109    let retained_commits =
110        u64::try_from(retained_commits).map_err(|_| invalid_local_rebootstrap_receipt())?;
111    let reset_subscriptions =
112        u64::try_from(reset_subscriptions).map_err(|_| invalid_local_rebootstrap_receipt())?;
113    if retained_commits > MAX_JS_SAFE_INTEGER || reset_subscriptions > MAX_JS_SAFE_INTEGER {
114        return Err(invalid_local_rebootstrap_receipt());
115    }
116    serde_json::to_string(&PersistedLocalDataRebootstrapReceipt {
117        version: LOCAL_REBOOTSTRAP_RECEIPT_VERSION,
118        retained_commits,
119        reset_subscriptions,
120    })
121    .map_err(|_| invalid_local_rebootstrap_receipt())
122}
123
124fn decode_local_rebootstrap_receipt(value: &str) -> Result<(usize, usize), String> {
125    // Pre-0.15.36 markers proved application but did not retain the receipt.
126    if value == "v1" {
127        return Ok((0, 0));
128    }
129    let receipt: PersistedLocalDataRebootstrapReceipt =
130        serde_json::from_str(value).map_err(|_| invalid_local_rebootstrap_receipt())?;
131    if receipt.version != LOCAL_REBOOTSTRAP_RECEIPT_VERSION
132        || receipt.retained_commits > MAX_JS_SAFE_INTEGER
133        || receipt.reset_subscriptions > MAX_JS_SAFE_INTEGER
134    {
135        return Err(invalid_local_rebootstrap_receipt());
136    }
137    Ok((
138        usize::try_from(receipt.retained_commits)
139            .map_err(|_| invalid_local_rebootstrap_receipt())?,
140        usize::try_from(receipt.reset_subscriptions)
141            .map_err(|_| invalid_local_rebootstrap_receipt())?,
142    ))
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146enum SubState {
147    Active,
148    Revoked,
149    Failed,
150}
151
152#[cfg(test)]
153mod observation_tests {
154    use super::*;
155    use crate::native_transport::HostTransport;
156    use crate::values::encode_row_json;
157    use serde_json::json;
158
159    fn client() -> SyncClient {
160        SyncClient::new(
161            "retry-test".to_owned(),
162            &json!({
163                "version": 1,
164                "tables": [{
165                    "name": "tasks",
166                    "primaryKey": "id",
167                    "columns": [
168                        { "name": "id", "type": "string", "nullable": false },
169                        { "name": "project_id", "type": "string", "nullable": false }
170                    ],
171                    "scopes": [{ "pattern": "project:{project_id}" }]
172                }]
173            }),
174            ClientLimits::default(),
175        )
176        .expect("test client")
177    }
178
179    #[cfg(feature = "e2ee")]
180    #[test]
181    fn an_unresolvable_key_id_becomes_a_durable_rejection_at_the_push_seam() {
182        let schema = json!({
183            "version": 1,
184            "tables": [{
185                "name": "secrets",
186                "primaryKey": "id",
187                "columns": [
188                    { "name": "id", "type": "string", "nullable": false },
189                    { "name": "encryption_key_id", "type": "string", "nullable": true },
190                    { "name": "note", "type": "bytes", "nullable": true,
191                      "encrypted": true, "declaredType": "string" }
192                ],
193                "scopes": []
194            }]
195        });
196        let mut client = SyncClient::new("encrypt-reject".into(), &schema, ClientLimits::default())
197            .expect("test client");
198        let mut config = crate::values::EncryptionConfig::default();
199        config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
200        config
201            .key_id_columns
202            .insert("secrets".to_owned(), "encryption_key_id".to_owned());
203        client.set_encryption(config);
204        // The row exists, but its key selector is absent. The encrypted edit
205        // authors locally and fails at the push seam.
206        client
207            .write_base_row(
208                "secrets",
209                &vec![Some(ColumnValue::String("ghost".into())), None, None],
210                1,
211            )
212            .unwrap();
213        client.rebuild_overlay();
214        let commit_id = client
215            .patch(
216                "secrets",
217                "ghost",
218                Map::from_iter([("note".to_owned(), json!("x"))]),
219                None,
220            )
221            .expect("patch records without an author-time encrypt failure");
222        assert_eq!(client.pending_commit_ids(), vec![commit_id.clone()]);
223        // ...and the push seam raises a durable local rejection, not a throw.
224        assert!(client.drop_unencodable_outbox().unwrap());
225        assert!(client.pending_commit_ids().is_empty());
226        let rejections = client.rejections();
227        assert_eq!(rejections.len(), 1);
228        assert_eq!(rejections[0].code, "client.encrypt_failed");
229        assert_eq!(rejections[0].client_commit_id, commit_id);
230        assert_eq!(
231            client
232                .commit_outcome(&commit_id)
233                .unwrap()
234                .map(|outcome| outcome.status),
235            Some(CommitOutcomeStatus::Rejected)
236        );
237    }
238
239    #[test]
240    fn patch_with_a_scope_column_matches_the_stored_local_row() {
241        let mut client = client();
242        client.create_synced_tables().unwrap();
243        client
244            .conn
245            .execute(
246                "INSERT INTO tasks (id, project_id, _syncular_version) VALUES ('t1', 'p1', 1)",
247                [],
248            )
249            .unwrap();
250
251        // §3.4 rule 5: a value equal to the stored local row is a no-op, so
252        // the scope column leaves the presence set.
253        client
254            .patch(
255                "tasks",
256                "t1",
257                Map::from_iter([
258                    ("project_id".to_owned(), json!("p1")),
259                    ("id".to_owned(), json!("t1")),
260                ]),
261                None,
262            )
263            .expect("an equal scope column is a no-op");
264        let values = client.outbox[0].ops[0]
265            .values
266            .clone()
267            .expect("the patch records values");
268        assert_eq!(values.get("id"), Some(&json!("t1")));
269        assert!(!values.contains_key("project_id"));
270
271        // A differing value stays rejected.
272        let differing = client.patch(
273            "tasks",
274            "t1",
275            Map::from_iter([("project_id".to_owned(), json!("p2"))]),
276            None,
277        );
278        assert!(differing
279            .expect_err("a differing scope column is rejected")
280            .contains("patch cannot write scope column"));
281
282        // An absent local row leaves nothing to prove equality against, so
283        // the patch fails closed.
284        let absent = client.patch(
285            "tasks",
286            "ghost",
287            Map::from_iter([("project_id".to_owned(), json!("p1"))]),
288            None,
289        );
290        assert!(absent
291            .expect_err("an absent local row is rejected")
292            .starts_with("sync.row_missing:"));
293        assert_eq!(client.pending_commit_ids().len(), 1);
294    }
295
296    #[test]
297    fn patch_scope_column_parity_for_the_primary_key_and_coerced_values() {
298        let schema = json!({
299            "version": 1,
300            "tables": [
301                {
302                    "name": "tenants",
303                    "primaryKey": "tenant_id",
304                    "columns": [
305                        { "name": "tenant_id", "type": "string", "nullable": false },
306                        { "name": "body", "type": "string", "nullable": false }
307                    ],
308                    "scopes": [{ "pattern": "tenant:{tenant_id}" }]
309                },
310                {
311                    "name": "buckets",
312                    "primaryKey": "id",
313                    "columns": [
314                        { "name": "id", "type": "string", "nullable": false },
315                        { "name": "bucket", "type": "float", "nullable": false },
316                        { "name": "body", "type": "string", "nullable": false }
317                    ],
318                    "scopes": [{ "pattern": "bucket:{bucket}" }]
319                }
320            ]
321        });
322        let mut client = SyncClient::new("scope-parity".into(), &schema, ClientLimits::default())
323            .expect("test client");
324        client.create_synced_tables().unwrap();
325
326        // A primary key that is also a scope column still needs a local base.
327        client
328            .conn
329            .execute(
330                "INSERT INTO tenants (tenant_id, body, _syncular_version) VALUES ('t1', 'seed', 1)",
331                [],
332            )
333            .unwrap();
334        client
335            .patch(
336                "tenants",
337                "t1",
338                Map::from_iter([
339                    ("tenant_id".to_owned(), json!("t1")),
340                    ("body".to_owned(), json!("created")),
341                ]),
342                None,
343            )
344            .expect("the primary-key scope column is proven equal by construction");
345
346        // A stored REAL scope value coerces from a supplied integer.
347        client
348            .conn
349            .execute(
350                "INSERT INTO buckets (id, bucket, body, _syncular_version) VALUES ('b1', 2.0, 'seed', 1)",
351                [],
352            )
353            .unwrap();
354        client
355            .patch(
356                "buckets",
357                "b1",
358                Map::from_iter([
359                    ("bucket".to_owned(), json!(2)),
360                    ("body".to_owned(), json!("coerced")),
361                ]),
362                None,
363            )
364            .expect("the stored REAL value coerces from a supplied integer");
365        let values = client.outbox[1].ops[0]
366            .values
367            .clone()
368            .expect("the patch records values");
369        assert!(!values.contains_key("bucket"));
370
371        // A differing value stays rejected.
372        let differing = client.patch(
373            "buckets",
374            "b1",
375            Map::from_iter([("bucket".to_owned(), json!(3))]),
376            None,
377        );
378        assert!(differing
379            .expect_err("a differing scope value is rejected")
380            .contains("patch cannot write scope column"));
381    }
382
383    #[cfg(feature = "e2ee")]
384    #[test]
385    fn stored_key_fallback_resolves_an_integer_primary_key() {
386        let schema = json!({
387            "version": 1,
388            "tables": [{
389                "name": "patients",
390                "primaryKey": "id",
391                "columns": [
392                    { "name": "id", "type": "integer", "nullable": false },
393                    { "name": "encryption_key_id", "type": "string", "nullable": true },
394                    { "name": "note", "type": "bytes", "nullable": true,
395                      "encrypted": true, "declaredType": "string" }
396                ],
397                "scopes": []
398            }]
399        });
400        let mut client = SyncClient::new("int-key".into(), &schema, ClientLimits::default())
401            .expect("test client");
402        client.create_synced_tables().unwrap();
403        client
404            .conn
405            .execute(
406                "INSERT INTO patients (id, encryption_key_id, note, _syncular_version)
407                 VALUES (5, 'k1', NULL, 1)",
408                [],
409            )
410            .unwrap();
411        let mut config = crate::values::EncryptionConfig::default();
412        config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
413        config
414            .key_id_columns
415            .insert("patients".to_owned(), "encryption_key_id".to_owned());
416        client.set_encryption(config);
417        let table = client.schema.table("patients").unwrap();
418        let fallback = client
419            .stored_key_fallback(table, "5")
420            .expect("the stored integer key resolves the fallback");
421        assert_eq!(fallback[1], Some(ColumnValue::String("k1".to_owned())));
422    }
423
424    #[test]
425    fn blob_staging_failures_preserve_durable_state_and_allow_retry() {
426        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
427            {"name":"id","type":"string","nullable":false},
428            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
429        for state in ["absent", "cached", "pinned"] {
430            let path = std::env::temp_dir().join(format!(
431                "syncular-blob-stage-{}.sqlite",
432                uuid::Uuid::new_v4()
433            ));
434            let mut client = SyncClient::open_path(
435                "stage-test".into(),
436                &schema,
437                Default::default(),
438                path.to_str().unwrap(),
439            )
440            .unwrap();
441            let bytes = b"atomic stage";
442            if state != "absent" {
443                client
444                    .upload_blob(bytes, Some("text/plain".into()), None)
445                    .unwrap();
446                if state == "cached" {
447                    client
448                        .conn
449                        .execute("DELETE FROM _syncular_blob_uploads", [])
450                        .unwrap();
451                }
452            }
453            let bodies = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
454            client
455                .conn
456                .execute_batch(
457                    "CREATE TRIGGER fail_stage_insert BEFORE INSERT ON _syncular_blobs
458                   BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;
459                 CREATE TRIGGER fail_stage_update BEFORE UPDATE ON _syncular_blobs
460                   BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;",
461                )
462                .unwrap();
463            let error = client
464                .upload_blob(bytes, Some("application/octet-stream".into()), None)
465                .unwrap_err();
466            assert!(error.contains("injected stage failure"), "{state}: {error}");
467            assert!(client.conn.is_autocommit());
468            let immediate = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
469            drop(client);
470            let mut reopened = SyncClient::open_path(
471                "stage-test".into(),
472                &schema,
473                Default::default(),
474                path.to_str().unwrap(),
475            )
476            .unwrap();
477            let durable = reopened
478                .query("SELECT * FROM _syncular_blobs", &[])
479                .unwrap();
480            reopened
481                .conn
482                .execute_batch("DROP TRIGGER fail_stage_insert; DROP TRIGGER fail_stage_update")
483                .unwrap();
484            let retried = reopened.upload_blob(bytes, None, None).unwrap();
485            assert_eq!(reopened.upload_blob(bytes, None, None).unwrap(), retried);
486            assert_eq!(
487                reopened
488                    .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
489                    .unwrap(),
490                vec![Map::from_iter([(
491                    "blob_id".into(),
492                    Value::from(blob_id_for(bytes)),
493                )])]
494            );
495            drop(reopened);
496            std::fs::remove_file(&path).unwrap();
497            assert_eq!(durable, bodies, "{state}: reopened body state");
498            assert_eq!(immediate, bodies, "{state}: immediate body state");
499        }
500    }
501
502    #[test]
503    fn blob_upload_failures_preserve_pins_and_original_commits_after_file_reopen() {
504        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
505            {"name":"id","type":"string","nullable":false},
506            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
507        for fault in [
508            "missing",
509            "body-type",
510            "length",
511            "hash",
512            "pin-metadata",
513            "read",
514            "pin-delete",
515        ] {
516            let path = std::env::temp_dir().join(format!(
517                "syncular-blob-upload-{}.sqlite",
518                uuid::Uuid::new_v4()
519            ));
520            let mut client = SyncClient::open_path(
521                "upload-test".into(),
522                &schema,
523                Default::default(),
524                path.to_str().unwrap(),
525            )
526            .unwrap();
527            let bytes = b"durable upload";
528            let ref_value = client
529                .upload_blob(bytes, Some("text/plain".into()), None)
530                .unwrap();
531            let commit = client
532                .mutate(vec![Mutation::Upsert {
533                    table: "attachments".into(),
534                    values: Map::from_iter([
535                        ("id".into(), Value::from("pending")),
536                        (
537                            "file".into(),
538                            Value::from(format!(
539                                "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
540                                blob_id_for(bytes),
541                                bytes.len()
542                            )),
543                        ),
544                    ]),
545                    base_version: None,
546                }])
547                .unwrap();
548            client
549                .conn
550                .execute_batch("CREATE TABLE saved_bodies AS SELECT * FROM _syncular_blobs")
551                .unwrap();
552            client.conn.execute_batch(match fault {
553                "missing" => "DELETE FROM _syncular_blobs",
554                "body-type" => "UPDATE _syncular_blobs SET bytes = 'wrong type'",
555                "length" => "UPDATE _syncular_blobs SET byte_length = byte_length + 1",
556                "hash" => "UPDATE _syncular_blobs SET bytes = zeroblob(byte_length)",
557                "pin-metadata" => "UPDATE _syncular_blob_uploads SET media_type = x'ff'",
558                "read" => "ALTER TABLE _syncular_blobs RENAME TO unavailable_bodies",
559                _ => "CREATE TRIGGER fail_pin_delete BEFORE DELETE ON _syncular_blob_uploads BEGIN SELECT RAISE(ABORT, 'injected pin deletion failure'); END",
560            }).unwrap();
561            let state = if fault == "read" {
562                Vec::new()
563            } else {
564                client.query("SELECT * FROM _syncular_blobs", &[]).unwrap()
565            };
566            let mut transport = CountingRealtimeTransport::default();
567            let error = client.flush_blob_uploads(&mut transport).unwrap_err();
568            assert_eq!(
569                error.code,
570                if matches!(fault, "read" | "pin-delete") {
571                    "client.failed"
572                } else {
573                    "sync.local_corrupt"
574                },
575                "{fault}"
576            );
577            assert_eq!(
578                transport.blob_uploads.len(),
579                usize::from(fault == "pin-delete"),
580                "{fault}: corrupt bytes must never be uploaded"
581            );
582            assert_eq!(client.pending_commit_ids(), std::slice::from_ref(&commit));
583            if fault != "read" {
584                assert_eq!(
585                    client.query("SELECT * FROM _syncular_blobs", &[]).unwrap(),
586                    state
587                );
588            } else {
589                client
590                    .conn
591                    .execute_batch("ALTER TABLE unavailable_bodies RENAME TO _syncular_blobs")
592                    .unwrap();
593            }
594            drop(client);
595            let mut reopened = SyncClient::open_path(
596                "upload-test".into(),
597                &schema,
598                Default::default(),
599                path.to_str().unwrap(),
600            )
601            .unwrap();
602            assert_eq!(reopened.pending_commit_ids(), std::slice::from_ref(&commit));
603            if fault != "read" {
604                assert_eq!(
605                    reopened
606                        .query("SELECT * FROM _syncular_blobs", &[])
607                        .unwrap(),
608                    state
609                );
610            }
611            if fault == "pin-delete" {
612                reopened
613                    .conn
614                    .execute_batch("DROP TRIGGER fail_pin_delete")
615                    .unwrap();
616            }
617            if fault == "pin-metadata" {
618                reopened
619                    .conn
620                    .execute(
621                        "UPDATE _syncular_blob_uploads SET media_type = 'text/plain'",
622                        [],
623                    )
624                    .unwrap();
625            }
626            reopened
627                .conn
628                .execute_batch(
629                    "DELETE FROM _syncular_blobs;
630                     INSERT INTO _syncular_blobs SELECT * FROM saved_bodies",
631                )
632                .unwrap();
633            transport.blob_uploads.clear();
634            reopened.flush_blob_uploads(&mut transport).unwrap();
635            assert_eq!(
636                transport.blob_uploads,
637                [(blob_id_for(bytes), bytes.to_vec())]
638            );
639            assert_eq!(
640                reopened
641                    .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
642                    .unwrap(),
643                Vec::<Map<String, Value>>::new()
644            );
645            assert_eq!(reopened.pending_commit_ids(), [commit]);
646            assert_eq!(
647                reopened
648                    .get_cached_blob_bytes(ref_value["blobId"].as_str().unwrap())
649                    .unwrap()
650                    .unwrap()
651                    .bytes,
652                bytes
653            );
654            drop(reopened);
655            std::fs::remove_file(path).unwrap();
656        }
657    }
658
659    #[test]
660    fn incoming_frame_transactions_preserve_prefix_cursor_and_revision_after_reopen() {
661        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
662            {"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false}],
663            "scopes":[{"pattern":"project:{project_id}"}]}]});
664        for realtime in [false, true] {
665            for fault in [
666                "none",
667                "row",
668                "revision",
669                "commit",
670                "cursor",
671                "error",
672                "missing-end",
673            ] {
674                if realtime && fault == "missing-end" {
675                    continue;
676                }
677                let path = std::env::temp_dir()
678                    .join(format!("syncular-frame-{}.sqlite", uuid::Uuid::new_v4()));
679                let mut client = SyncClient::open_path(
680                    "frame-test".into(),
681                    &schema,
682                    Default::default(),
683                    path.to_str().unwrap(),
684                )
685                .unwrap();
686                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
687                client
688                    .subscribe(
689                        "tasks".into(),
690                        "tasks".into(),
691                        vec![("project_id".into(), vec!["p1".into()])],
692                        None,
693                    )
694                    .unwrap();
695                client.subs[0].cursor = 0;
696                client.subs[0].synced_once = true;
697                client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
698                client.persist_sub(&client.subs[0]).unwrap();
699                client.realtime_state = RealtimeState::Connected;
700                client.realtime_reason_code = None;
701                client.drain_change_batches();
702                let mut transport = CountingRealtimeTransport::default();
703                let (_, meta) = client.build_request(false);
704                let mut response = Message {
705                    wire_version: WIRE_VERSION,
706                    msg_kind: MsgKind::Response,
707                    frames: vec![
708                        Frame::RespHeader {
709                            required_schema_version: None,
710                            latest_schema_version: None,
711                            log_epoch: Some("epoch-1".into()),
712                            reset_required: Some(false),
713                        },
714                        Frame::SubStart {
715                            id: "tasks".into(),
716                            status: SubStatus::Active,
717                            reason_code: "".into(),
718                            effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
719                            bootstrap: false,
720                        },
721                    ],
722                };
723                for (index, id) in ["first", "second"].iter().enumerate() {
724                    let values = Map::from_iter([
725                        ("id".into(), Value::from(*id)),
726                        ("project_id".into(), Value::from("p1")),
727                    ]);
728                    response.frames.push(Frame::Commit {
729                        commit_seq: index as i64 + 1,
730                        actor_id: "writer".into(),
731                        created_at_ms: 1,
732                        tables: vec!["tasks".into()],
733                        changes: vec![ssp2::model::Change {
734                            table_index: 0,
735                            row_id: (*id).into(),
736                            op: Op::Upsert,
737                            row_version: Some(index as i64 + 1),
738                            scopes: vec![("project_id".into(), "p1".into())],
739                            row: Some(
740                                encode_row_json(
741                                    client.schema.table("tasks").unwrap(),
742                                    id,
743                                    &values,
744                                    &client.encryption,
745                                )
746                                .unwrap(),
747                            ),
748                        }],
749                    });
750                }
751                response.frames.push(Frame::SubEnd {
752                    next_cursor: 2,
753                    bootstrap_state: None,
754                });
755                let valid = response.clone();
756                match fault {
757                    "row" => {
758                        let Frame::Commit {changes, ..} = &mut response.frames[3] else {panic!("second frame")};
759                        let mut malformed = changes[0].clone(); malformed.row_id = "malformed".into(); malformed.row = Some(vec![]); changes.push(malformed);
760                    },
761                    "revision" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' AND NEW.value = '2' AND EXISTS (SELECT 1 FROM _syncular_base_tasks WHERE id = 'second') BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END").unwrap(),
762                    "commit" => client.conn.execute_batch("PRAGMA foreign_keys = ON; CREATE TABLE frame_parent(id INTEGER PRIMARY KEY); CREATE TABLE frame_child(id INTEGER REFERENCES frame_parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_frame AFTER INSERT ON _syncular_base_tasks WHEN NEW.id = 'second' BEGIN INSERT INTO frame_child VALUES(1); END").unwrap(),
763                    "cursor" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected cursor failure'); END").unwrap(),
764                    "error" => {response.frames.truncate(3);response.frames.push(Frame::Error {code:"sync.invalid_request".into(),message:"later error".into(),category:"protocol".into(),retryable:false,recommended_action:"retry".into(),details:None});},
765                    "missing-end" => {response.frames.truncate(3);},
766                    _ => {}
767                }
768                if realtime {
769                    client.on_realtime_binary(&mut transport, &encode_message(&response));
770                } else {
771                    assert_eq!(
772                        matches!(
773                            client.process_response(&mut transport, response, &meta),
774                            SyncOutcome::Ok(_)
775                        ),
776                        fault == "none",
777                        "{fault}"
778                    );
779                }
780                assert!(
781                    client.conn.is_autocommit(),
782                    "no open transaction after {realtime}/{fault}"
783                );
784                let count = if matches!(fault, "none" | "cursor") {
785                    2
786                } else {
787                    1
788                };
789                let expected_revision = count + u64::from(realtime && fault != "none");
790                assert_eq!(
791                    client.local_revision(),
792                    expected_revision,
793                    "{realtime}/{fault}"
794                );
795                let batches = client.drain_change_batches();
796                assert_eq!(
797                    batches.len(),
798                    expected_revision as usize,
799                    "{realtime}/{fault}"
800                );
801                let row_batches: Vec<_> = batches
802                    .iter()
803                    .filter(|batch| !batch.tables.is_empty())
804                    .collect();
805                assert_eq!(row_batches.len(), count as usize);
806                assert_eq!(
807                    row_batches
808                        .iter()
809                        .map(|b| b.revision.clone())
810                        .collect::<Vec<_>>(),
811                    (1..=count).map(|r| r.to_string()).collect::<Vec<_>>()
812                );
813                for table in ["tasks", "_syncular_base_tasks"] {
814                    assert_eq!(
815                        client
816                            .conn
817                            .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
818                                .get::<_, u64>(0))
819                            .unwrap(),
820                        count,
821                        "{realtime}/{fault}/{table}"
822                    );
823                }
824                assert_eq!(client.subs[0].cursor, if fault == "none" { 2 } else { 0 });
825                assert_eq!(
826                    transport
827                        .messages
828                        .iter()
829                        .filter(|m| m.contains("\"ack\""))
830                        .count(),
831                    usize::from(fault == "none")
832                );
833                if matches!(fault, "revision" | "commit" | "cursor") {
834                    client
835                        .conn
836                        .execute_batch("DROP TRIGGER fail_frame")
837                        .unwrap();
838                }
839                drop(client);
840                let mut reopened = SyncClient::open_path(
841                    "frame-test".into(),
842                    &schema,
843                    Default::default(),
844                    path.to_str().unwrap(),
845                )
846                .unwrap();
847                assert_eq!(reopened.local_revision(), expected_revision);
848                assert_eq!(
849                    reopened
850                        .query("SELECT id FROM tasks ORDER BY id", &[])
851                        .unwrap()
852                        .len(),
853                    count as usize
854                );
855                assert_eq!(reopened.subs[0].cursor, if fault == "none" { 2 } else { 0 });
856                let (_, meta) = reopened.build_request(false);
857                assert!(matches!(
858                    reopened.process_response(&mut transport, valid, &meta),
859                    SyncOutcome::Ok(_)
860                ));
861                assert_eq!(
862                    reopened
863                        .query("SELECT id FROM tasks ORDER BY id", &[])
864                        .unwrap()
865                        .len(),
866                    2
867                );
868                assert_eq!(reopened.subs[0].cursor, 2);
869                drop(reopened);
870                std::fs::remove_file(path).unwrap();
871            }
872        }
873    }
874
875    #[test]
876    fn failed_subscription_control_persistence_rolls_back_rows_outbox_and_memory() {
877        for status in [SubStatus::Reset, SubStatus::Revoked] {
878            for fault in ["subscription", "revision"] {
879                let mut client = client();
880                client
881                    .subscribe(
882                        "tasks".into(),
883                        "tasks".into(),
884                        vec![("project_id".into(), vec!["p1".into()])],
885                        None,
886                    )
887                    .unwrap();
888                client.subs[0].cursor = 41;
889                client.subs[0].synced_once = true;
890                client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
891                client.persist_sub(&client.subs[0]).unwrap();
892                client
893                    .mutate(vec![Mutation::Upsert {
894                        table: "tasks".into(),
895                        values: Map::from_iter([
896                            ("id".into(), Value::from("pending")),
897                            ("project_id".into(), Value::from("p1")),
898                        ]),
899                        base_version: None,
900                    }])
901                    .unwrap();
902                let ids = client.pending_commit_ids();
903                let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
904                let revision = client.local_revision();
905                client.drain_change_batches();
906                let (_, meta) = client.build_request(false);
907                client.conn.execute_batch(if fault == "subscription" {
908                    "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected subscription failure'); END"
909                } else {
910                    "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END"
911                }).unwrap();
912                let mut transport = CountingRealtimeTransport::default();
913                let mut report = SyncReport::default();
914                // Reset changes window completeness and therefore needs a revision.
915                if status == SubStatus::Reset {
916                    client.conn.execute("INSERT INTO _syncular_windows(base,unit,sub_id) VALUES ('base','p1','tasks')", []).unwrap();
917                }
918                let result = client.process_section(
919                    &mut transport,
920                    "tasks",
921                    status,
922                    "sync.scope_revoked",
923                    vec![],
924                    vec![],
925                    Some((42, None)),
926                    &meta,
927                    &mut report,
928                );
929                assert!(result.is_err(), "{status:?}/{fault}");
930                assert!(client.conn.is_autocommit());
931                assert_eq!(client.subs[0].cursor, 41);
932                assert_eq!(client.subs[0].state, SubState::Active);
933                assert_eq!(client.pending_commit_ids(), ids);
934                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
935                assert!(client.rejections.is_empty());
936                assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
937                assert_eq!(client.local_revision(), revision);
938                assert!(client.drain_change_batches().is_empty());
939                assert!(report.revoked.is_empty() && report.resets.is_empty());
940            }
941        }
942    }
943
944    #[test]
945    fn rows_segment_blocks_commit_independently_and_clear_with_the_first_block() {
946        let mut client = client();
947        client
948            .subscribe(
949                "tasks".into(),
950                "tasks".into(),
951                vec![("project_id".into(), vec!["p1".into()])],
952                None,
953            )
954            .unwrap();
955        client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
956        client.conn.execute_batch("INSERT INTO _syncular_base_tasks VALUES ('stale','p1',1); INSERT INTO tasks VALUES ('stale','p1',1);
957            CREATE TRIGGER fail_block BEFORE INSERT ON _syncular_base_tasks WHEN NEW.id = 'bad' BEGIN SELECT RAISE(FAIL,'injected later block failure'); END").unwrap();
958        let row = |id: &str| ssp2::segment::SegmentRow {
959            server_version: 1,
960            values: vec![
961                Some(ColumnValue::String(id.into())),
962                Some(ColumnValue::String("p1".into())),
963            ],
964        };
965        let segment = RowsSegment {
966            table: "tasks".into(),
967            schema_version: 1,
968            columns: client.schema.table("tasks").unwrap().wire_columns.clone(),
969            blocks: vec![vec![row("first")], vec![row("second"), row("bad")]],
970        };
971        assert!(client.apply_segment(0, &segment, true).is_err());
972        assert!(client.conn.is_autocommit());
973        assert_eq!(
974            client
975                .query("SELECT id FROM tasks ORDER BY id", &[])
976                .unwrap(),
977            vec![Map::from_iter([("id".into(), Value::from("first"))])]
978        );
979        assert_eq!(client.local_revision(), 1);
980        assert_eq!(client.drain_change_batches().len(), 1);
981        assert_eq!(client.subs[0].cursor, -1);
982        client
983            .conn
984            .execute_batch("DROP TRIGGER fail_block")
985            .unwrap();
986        assert!(matches!(client.apply_segment(0, &segment, true), Ok(3)));
987        assert_eq!(client.local_revision(), 3);
988        assert_eq!(client.drain_change_batches().len(), 2);
989    }
990
991    #[test]
992    fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
993        assert_eq!(
994            client().diagnostics_storage().blob_cache_bytes_approx,
995            Some(0)
996        );
997        let client = SyncClient::new(
998            "diagnostic-storage".to_owned(),
999            &json!({
1000                "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
1001                    "columns": [{"name": "id", "type": "string", "nullable": false},
1002                        {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
1003            }),
1004            ClientLimits {
1005                blob_cache_max_bytes: Some(5),
1006                ..Default::default()
1007            },
1008        )
1009        .unwrap();
1010        let empty = client.diagnostics_storage();
1011        assert_eq!(empty.status, "healthy");
1012        assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
1013        assert_eq!(empty.retained_outcome_entries, Some(0));
1014        assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
1015        assert_eq!(empty.blob_cache_bytes_approx, Some(0));
1016        client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
1017            INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
1018                VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
1019            INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)
1020                VALUES ('body',zeroblob(8),8,NULL,0);").unwrap();
1021        let written = client.diagnostics_storage();
1022        assert_eq!(written.status, "pressure");
1023        assert_eq!(written.pending_outbox_bytes_approx, Some(2));
1024        assert_eq!(written.retained_outcome_entries, Some(2));
1025        assert_eq!(written.retained_outcome_bytes_approx, Some(6));
1026        assert_eq!(written.blob_cache_bytes_approx, Some(8));
1027        assert_eq!(
1028            written.pressure_reason_code.as_deref(),
1029            Some("client.blob_cache_over_limit")
1030        );
1031        client
1032            .conn
1033            .execute_batch(
1034                "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
1035            DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
1036            )
1037            .unwrap();
1038        let staged = client.diagnostics_storage();
1039        assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
1040        assert_eq!(staged.retained_outcome_entries, Some(0));
1041        assert_eq!(staged.blob_cache_bytes_approx, Some(0));
1042        assert_eq!(staged.status, "healthy");
1043        client.conn.execute_batch("ROLLBACK").unwrap();
1044        assert_eq!(client.diagnostics_storage(), written);
1045        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
1046        let unreadable = client.diagnostics_storage();
1047        assert_eq!(unreadable.status, "unreadable");
1048        assert!(unreadable.database_bytes_approx.is_none());
1049        assert!(unreadable.pending_outbox_bytes_approx.is_none());
1050        assert!(unreadable.retained_outcome_entries.is_none());
1051        assert!(unreadable.retained_outcome_bytes_approx.is_none());
1052        assert!(unreadable.blob_cache_bytes_approx.is_none());
1053        client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
1054        let restored = client.diagnostics_storage();
1055        assert_eq!(restored.status, written.status);
1056        assert_eq!(
1057            restored.retained_outcome_bytes_approx,
1058            written.retained_outcome_bytes_approx
1059        );
1060        client.conn.set_prepared_statement_cache_capacity(0);
1061        assert_eq!(client.diagnostics_storage(), restored);
1062    }
1063
1064    /// Answers every round with one server `ERROR` frame (§1.6).
1065    struct ServerErrorTransport {
1066        code: &'static str,
1067        retryable: bool,
1068    }
1069
1070    impl Transport for ServerErrorTransport {
1071        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1072            Ok(encode_message(&Message {
1073                wire_version: WIRE_VERSION,
1074                msg_kind: MsgKind::Response,
1075                frames: vec![
1076                    Frame::RespHeader {
1077                        required_schema_version: None,
1078                        latest_schema_version: None,
1079                        log_epoch: Some("epoch-1".to_owned()),
1080                        reset_required: Some(false),
1081                    },
1082                    Frame::Error {
1083                        code: self.code.to_owned(),
1084                        message: "server error".to_owned(),
1085                        category: "internal".to_owned(),
1086                        retryable: self.retryable,
1087                        recommended_action: "retryLater".to_owned(),
1088                        details: None,
1089                    },
1090                ],
1091            }))
1092        }
1093
1094        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1095            self.sync(request)
1096        }
1097
1098        fn download_segment(
1099            &mut self,
1100            _request: &SegmentRequest,
1101            _on_progress: &mut dyn FnMut(u64),
1102        ) -> Result<Vec<u8>, TransportError> {
1103            Err(TransportError::new("sync.transport_failed", "offline"))
1104        }
1105
1106        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1107            Ok(())
1108        }
1109
1110        fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1111            Ok(())
1112        }
1113
1114        fn realtime_close(&mut self) -> Result<(), TransportError> {
1115            Ok(())
1116        }
1117    }
1118
1119    #[test]
1120    fn retryable_server_error_schedules_a_background_retry() {
1121        let mut client = client();
1122        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1123        let mut transport = ServerErrorTransport {
1124            code: "sync.internal_error",
1125            retryable: true,
1126        };
1127        assert!(matches!(
1128            client.sync(&mut transport),
1129            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
1130        ));
1131        assert!(matches!(
1132            client.drain_sync_intents().as_slice(),
1133            [SyncIntent::Background { delay_ms: 250 }]
1134        ));
1135        assert_eq!(
1136            client.progress().snapshot().unwrap().retry_delay_ms,
1137            Some(250)
1138        );
1139        let mut transport = ServerErrorTransport {
1140            code: "sync.invalid_request",
1141            retryable: false,
1142        };
1143        assert!(matches!(
1144            client.sync(&mut transport),
1145            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"
1146        ));
1147        assert!(client.drain_sync_intents().is_empty());
1148        let progress = client.progress().snapshot().unwrap();
1149        assert_eq!(progress.state, ProgressState::Failed);
1150        assert_eq!(progress.retry_delay_ms, None);
1151    }
1152
1153    #[test]
1154    fn failed_progress_retry_delay_doubles_to_the_cap() {
1155        let mut client = client();
1156        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1157        let mut transport = ServerErrorTransport {
1158            code: "sync.internal_error",
1159            retryable: true,
1160        };
1161        let delays: Vec<Option<u64>> = (0..9)
1162            .map(|_| {
1163                client.sync(&mut transport);
1164                client.progress().snapshot().unwrap().retry_delay_ms
1165            })
1166            .collect();
1167        assert_eq!(
1168            delays,
1169            [250, 500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000].map(Some)
1170        );
1171    }
1172
1173    #[test]
1174    fn background_retry_deadlines_back_off_and_reset() {
1175        let mut client = client();
1176        client.schedule_background_retry();
1177        assert!(matches!(
1178            client.drain_sync_intents().as_slice(),
1179            [SyncIntent::Background { delay_ms: 250 }]
1180        ));
1181        client.schedule_background_retry();
1182        assert!(matches!(
1183            client.drain_sync_intents().as_slice(),
1184            [SyncIntent::Background { delay_ms: 500 }]
1185        ));
1186        client.reset_background_retry();
1187        client.schedule_background_retry();
1188        assert!(matches!(
1189            client.drain_sync_intents().as_slice(),
1190            [SyncIntent::Background { delay_ms: 250 }]
1191        ));
1192    }
1193
1194    #[test]
1195    fn log_epoch_reset_requests_an_immediate_follow_up_round() {
1196        let mut client = client();
1197        client
1198            .subscribe(
1199                "epoch-tasks".to_owned(),
1200                "tasks".to_owned(),
1201                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1202                None,
1203            )
1204            .expect("subscribe");
1205        client.drain_sync_intents();
1206
1207        client
1208            .run_log_epoch_reset("epoch-2")
1209            .expect("reset partition log epoch");
1210
1211        assert!(matches!(
1212            client.drain_sync_intents().as_slice(),
1213            [SyncIntent::Interactive]
1214        ));
1215    }
1216
1217    #[derive(Default)]
1218    struct CountingRealtimeTransport {
1219        connects: usize,
1220        closes: usize,
1221        messages: Vec<String>,
1222        blob_uploads: Vec<(String, Vec<u8>)>,
1223        blob_download: Option<Vec<u8>>,
1224        sync_calls: usize,
1225        realtime_sync_calls: usize,
1226        refuse_connect: bool,
1227    }
1228
1229    impl Transport for CountingRealtimeTransport {
1230        fn blob_upload(
1231            &mut self,
1232            blob_id: &str,
1233            bytes: &[u8],
1234            _media_type: Option<&str>,
1235        ) -> Result<(), TransportError> {
1236            self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
1237            Ok(())
1238        }
1239
1240        fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
1241            self.blob_download
1242                .clone()
1243                .map(BlobDownload::Bytes)
1244                .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
1245        }
1246
1247        fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1248            self.sync_calls += 1;
1249            Err(TransportError::new("sync.transport_failed", "offline"))
1250        }
1251
1252        fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1253            self.realtime_sync_calls += 1;
1254            Err(TransportError::new("sync.transport_failed", "offline"))
1255        }
1256
1257        fn download_segment(
1258            &mut self,
1259            _request: &SegmentRequest,
1260            _on_progress: &mut dyn FnMut(u64),
1261        ) -> Result<Vec<u8>, TransportError> {
1262            Err(TransportError::new("sync.transport_failed", "offline"))
1263        }
1264
1265        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1266            self.connects += 1;
1267            if self.refuse_connect {
1268                return Err(TransportError::new("transport.failed", "refused"));
1269            }
1270            Ok(())
1271        }
1272
1273        fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
1274            self.messages.push(text.to_owned());
1275            Ok(())
1276        }
1277
1278        fn realtime_close(&mut self) -> Result<(), TransportError> {
1279            self.closes += 1;
1280            Ok(())
1281        }
1282    }
1283
1284    #[test]
1285    fn blob_results_own_downloaded_bytes() {
1286        let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
1287            {"name":"id","type":"string","nullable":false},
1288            {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
1289        let bytes = b"owned typed result".to_vec();
1290        let blob_id = blob_id_for(&bytes);
1291        let mut transport = CountingRealtimeTransport {
1292            blob_download: Some(bytes.clone()),
1293            ..Default::default()
1294        };
1295        let result = {
1296            let mut client =
1297                SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
1298                    .unwrap();
1299            let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
1300            assert_eq!(result.blob_id, blob_id);
1301            assert_eq!(result.byte_length, bytes.len() as i64);
1302            assert_eq!(result.bytes, bytes);
1303            assert_eq!(result.media_type, None);
1304            assert_eq!(
1305                client
1306                    .conn
1307                    .query_row(
1308                        "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
1309                        rusqlite::params![blob_id],
1310                        |row| row.get::<_, i64>(0),
1311                    )
1312                    .unwrap(),
1313                0
1314            );
1315            result
1316        };
1317        assert_eq!(result.bytes, bytes);
1318
1319        let mut client =
1320            SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
1321        assert_eq!(
1322            client
1323                .fetch_blob_bytes(&mut transport, "not json")
1324                .unwrap_err(),
1325            (
1326                "client.failed".to_owned(),
1327                "blob ref is not JSON".to_owned()
1328            )
1329        );
1330    }
1331
1332    #[test]
1333    fn visible_blob_references_survive_cache_trimming() {
1334        let schema = json!({"version":1,"tables":[
1335            {"name":"attachments","primaryKey":"id","columns":[
1336                {"name":"id","type":"string","nullable":false},
1337                {"name":"body","type":"blob_ref","nullable":true},
1338                {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
1339            {"name":"avatars","primaryKey":"id","columns":[
1340                {"name":"id","type":"string","nullable":false},
1341                {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
1342        ]});
1343        let bytes = b"referenced above cap".to_vec();
1344        let blob_id = blob_id_for(&bytes);
1345        let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
1346        let mut client = SyncClient::new(
1347            "visible-blob-refs".to_owned(),
1348            &schema,
1349            ClientLimits {
1350                blob_cache_max_bytes: Some(1),
1351                ..Default::default()
1352            },
1353        )
1354        .unwrap();
1355        client.conn.execute(
1356            "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
1357            rusqlite::params![reference, reference, reference, reference],
1358        ).unwrap();
1359        client
1360            .conn
1361            .execute(
1362                "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
1363                rusqlite::params![reference],
1364            )
1365            .unwrap();
1366        client.conn.execute(
1367            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
1368            [],
1369        ).unwrap();
1370        let mut transport = CountingRealtimeTransport {
1371            blob_download: Some(bytes.clone()),
1372            ..Default::default()
1373        };
1374
1375        assert_eq!(
1376            client
1377                .fetch_blob_bytes(&mut transport, &blob_id)
1378                .unwrap()
1379                .bytes,
1380            bytes
1381        );
1382        let rows = client
1383            .conn
1384            .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
1385            .unwrap()
1386            .query_map([], |row| row.get::<_, String>(0))
1387            .unwrap()
1388            .collect::<Result<Vec<_>, _>>()
1389            .unwrap();
1390        assert_eq!(rows, vec![blob_id]);
1391    }
1392
1393    #[test]
1394    fn realtime_connection_ownership_is_idempotent() {
1395        let mut client = client();
1396        let mut transport = CountingRealtimeTransport {
1397            connects: 0,
1398            closes: 0,
1399            ..Default::default()
1400        };
1401        client
1402            .connect_realtime(&mut transport)
1403            .expect("first connect");
1404        client
1405            .connect_realtime(&mut transport)
1406            .expect("idempotent connect");
1407        assert_eq!(transport.connects, 1);
1408        client.disconnect_realtime(&mut transport);
1409        client.disconnect_realtime(&mut transport);
1410        assert_eq!(transport.closes, 1);
1411        client
1412            .connect_realtime(&mut transport)
1413            .expect("deliberate reconnect");
1414        assert_eq!(transport.connects, 2);
1415    }
1416
1417    #[test]
1418    fn required_policy_refuses_a_round_without_a_socket_and_never_uses_http() {
1419        let mut client = client();
1420        client
1421            .set_realtime_policy(RealtimePolicy::Required)
1422            .expect("set policy");
1423        let mut transport = CountingRealtimeTransport::default();
1424        match client.sync(&mut transport) {
1425            SyncOutcome::RealtimeUnavailable {
1426                state,
1427                reason_code,
1428                retry_delay_ms,
1429            } => {
1430                assert_eq!(state, RealtimeState::Disconnected);
1431                assert_eq!(reason_code, None);
1432                assert_eq!(retry_delay_ms, 250);
1433            }
1434            other => panic!("expected RealtimeUnavailable, got {other:?}"),
1435        }
1436        assert_eq!(transport.sync_calls, 0);
1437        assert!(matches!(
1438            client.drain_sync_intents().as_slice(),
1439            [SyncIntent::Background { delay_ms: 250 }]
1440        ));
1441        let snapshot = client
1442            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1443            .expect("diagnostics");
1444        assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
1445        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Required);
1446        assert_eq!(snapshot.host.realtime_reason_code, None);
1447        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
1448        assert_eq!(
1449            snapshot
1450                .last_round
1451                .as_ref()
1452                .and_then(|round| round.error_code.clone()),
1453            Some(REALTIME_UNAVAILABLE_CODE.to_owned())
1454        );
1455    }
1456
1457    #[test]
1458    fn refused_handshake_is_a_visible_state_and_a_required_round_does_not_fall_back() {
1459        let mut client = client();
1460        client
1461            .set_realtime_policy(RealtimePolicy::Required)
1462            .expect("set policy");
1463        let mut transport = CountingRealtimeTransport {
1464            refuse_connect: true,
1465            ..Default::default()
1466        };
1467        assert!(client.connect_realtime(&mut transport).is_err());
1468        assert_eq!(client.realtime_state(), RealtimeState::Refused);
1469        let snapshot = client
1470            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1471            .expect("diagnostics");
1472        assert_eq!(
1473            snapshot.host.realtime_reason_code.as_deref(),
1474            Some("transport.failed")
1475        );
1476        match client.sync(&mut transport) {
1477            SyncOutcome::RealtimeUnavailable {
1478                state, reason_code, ..
1479            } => {
1480                assert_eq!(state, RealtimeState::Refused);
1481                assert_eq!(reason_code.as_deref(), Some("transport.failed"));
1482            }
1483            other => panic!("expected RealtimeUnavailable, got {other:?}"),
1484        }
1485        assert_eq!(transport.sync_calls, 0);
1486        assert_eq!(transport.realtime_sync_calls, 0);
1487    }
1488
1489    #[test]
1490    fn a_failed_socket_round_is_lost_and_the_next_required_round_refuses_http() {
1491        let mut client = client();
1492        client
1493            .set_realtime_policy(RealtimePolicy::Required)
1494            .expect("set policy");
1495        let mut transport = CountingRealtimeTransport::default();
1496        client.connect_realtime(&mut transport).expect("connect");
1497        assert_eq!(client.realtime_state(), RealtimeState::Connected);
1498        assert!(
1499            client.set_realtime_policy(RealtimePolicy::Off).is_err(),
1500            "a policy change cannot silently re-route a connected socket"
1501        );
1502        assert!(matches!(
1503            client.sync(&mut transport),
1504            SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.transport_failed"
1505        ));
1506        assert_eq!(client.realtime_state(), RealtimeState::Lost);
1507        assert_eq!(transport.realtime_sync_calls, 1);
1508        assert_eq!(transport.sync_calls, 0);
1509        let snapshot = client
1510            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1511            .expect("diagnostics");
1512        assert_eq!(
1513            snapshot.host.realtime_reason_code.as_deref(),
1514            Some("sync.transport_failed")
1515        );
1516        assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
1517        match client.sync(&mut transport) {
1518            SyncOutcome::RealtimeUnavailable {
1519                state, reason_code, ..
1520            } => {
1521                assert_eq!(state, RealtimeState::Lost);
1522                assert_eq!(reason_code.as_deref(), Some("sync.transport_failed"));
1523            }
1524            other => panic!("expected RealtimeUnavailable, got {other:?}"),
1525        }
1526        assert_eq!(transport.sync_calls, 0);
1527        client.disconnect_realtime(&mut transport);
1528        assert_eq!(client.realtime_state(), RealtimeState::Disconnected);
1529        assert_eq!(transport.closes, 1);
1530        client.connect_realtime(&mut transport).expect("reconnect");
1531        assert_eq!(client.realtime_state(), RealtimeState::Connected);
1532        assert_eq!(transport.connects, 2);
1533    }
1534
1535    #[test]
1536    fn off_policy_uses_http_and_refuses_an_explicit_connect() {
1537        let mut client = client();
1538        client
1539            .set_realtime_policy(RealtimePolicy::Off)
1540            .expect("set policy");
1541        let mut transport = CountingRealtimeTransport::default();
1542        assert!(client.connect_realtime(&mut transport).is_err());
1543        assert_eq!(client.realtime_state(), RealtimeState::Disabled);
1544        assert!(matches!(
1545            client.sync(&mut transport),
1546            SyncOutcome::Failed { .. }
1547        ));
1548        assert_eq!(transport.sync_calls, 1);
1549        assert_eq!(transport.realtime_sync_calls, 0);
1550        let snapshot = client
1551            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1552            .expect("diagnostics");
1553        assert_eq!(snapshot.host.realtime, RealtimeState::Disabled);
1554        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Off);
1555    }
1556
1557    #[test]
1558    fn optional_policy_keeps_the_http_round() {
1559        let mut client = client();
1560        let mut transport = CountingRealtimeTransport::default();
1561        assert!(matches!(
1562            client.sync(&mut transport),
1563            SyncOutcome::Failed { .. }
1564        ));
1565        assert_eq!(transport.sync_calls, 1);
1566        assert_eq!(transport.realtime_sync_calls, 0);
1567        let snapshot = client
1568            .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
1569            .expect("diagnostics");
1570        assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
1571        assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Optional);
1572    }
1573
1574    #[test]
1575    fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
1576        let mut client = client();
1577        client
1578            .subscribe(
1579                "repair-tasks".to_owned(),
1580                "tasks".to_owned(),
1581                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1582                None,
1583            )
1584            .expect("subscribe");
1585        {
1586            let sub = client
1587                .subs
1588                .iter_mut()
1589                .find(|sub| sub.id == "repair-tasks")
1590                .expect("subscription");
1591            sub.cursor = 42;
1592            sub.synced_once = true;
1593            let persisted = sub.clone();
1594            client.persist_sub(&persisted).unwrap();
1595        }
1596        for table in ["_syncular_base_tasks", "tasks"] {
1597            client
1598                .conn
1599                .execute(
1600                    &format!(
1601                        "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
1602                    ),
1603                    rusqlite::params!["server-row", "p1"],
1604                )
1605                .expect("seed server row");
1606        }
1607        let pending = client
1608            .mutate(vec![Mutation::Upsert {
1609                table: "tasks".to_owned(),
1610                values: Map::from_iter([
1611                    ("id".to_owned(), Value::from("offline-row")),
1612                    ("project_id".to_owned(), Value::from("p1")),
1613                ]),
1614                base_version: None,
1615            }])
1616            .expect("queue offline work");
1617        client.drain_change_batches();
1618        client.drain_sync_intents();
1619
1620        assert_eq!(
1621            client
1622                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1623                    rebootstrap_id: "support-case-001".to_owned(),
1624                })
1625                .expect("rebootstrap"),
1626            LocalDataRebootstrapResult {
1627                already_applied: false,
1628                retained_commits: 1,
1629                reset_subscriptions: 1,
1630            }
1631        );
1632        let visible_ids = client
1633            .conn
1634            .prepare("SELECT id FROM tasks ORDER BY id")
1635            .expect("prepare visible ids")
1636            .query_map([], |row| row.get::<_, String>(0))
1637            .expect("query visible ids")
1638            .collect::<Result<Vec<_>, _>>()
1639            .expect("collect visible ids");
1640        assert_eq!(visible_ids, vec!["offline-row"]);
1641        assert_eq!(client.pending_commit_ids(), vec![pending]);
1642        assert_eq!(
1643            client
1644                .subscription_state("repair-tasks")
1645                .expect("subscription")
1646                .cursor,
1647            -1
1648        );
1649        assert!(client.upgrading());
1650        assert!(client.sync_needed());
1651        assert!(matches!(
1652            client.drain_sync_intents().as_slice(),
1653            [SyncIntent::Interactive]
1654        ));
1655        assert_eq!(client.drain_change_batches().len(), 1);
1656
1657        assert_eq!(
1658            client
1659                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1660                    rebootstrap_id: "support-case-001".to_owned(),
1661                })
1662                .expect("idempotent retry"),
1663            LocalDataRebootstrapResult {
1664                already_applied: true,
1665                retained_commits: 1,
1666                reset_subscriptions: 1,
1667            }
1668        );
1669        assert!(client.drain_change_batches().is_empty());
1670    }
1671
1672    #[test]
1673    fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
1674        let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
1675        assert_eq!(
1676            decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
1677            (3, 4)
1678        );
1679        assert_eq!(
1680            decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
1681            (0, 0)
1682        );
1683        for malformed in [
1684            "",
1685            "{}",
1686            r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
1687            r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
1688            r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
1689        ] {
1690            assert_eq!(
1691                decode_local_rebootstrap_receipt(malformed)
1692                    .expect_err("malformed receipt must fail"),
1693                "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1694            );
1695        }
1696    }
1697
1698    #[test]
1699    fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
1700        let path = std::env::temp_dir().join(format!(
1701            "syncular-rebootstrap-receipt-{}.db",
1702            uuid::Uuid::new_v4()
1703        ));
1704        let schema = json!({
1705            "version": 1,
1706            "tables": [{
1707                "name": "tasks",
1708                "primaryKey": "id",
1709                "columns": [
1710                    { "name": "id", "type": "string", "nullable": false },
1711                    { "name": "project_id", "type": "string", "nullable": false }
1712                ],
1713                "scopes": [{ "pattern": "project:{project_id}" }]
1714            }]
1715        });
1716        let path_string = path.to_str().expect("UTF-8 temp path");
1717
1718        {
1719            let mut first = SyncClient::open_path(
1720                "repair-restart-client".to_owned(),
1721                &schema,
1722                ClientLimits::default(),
1723                path_string,
1724            )
1725            .expect("first open");
1726            first
1727                .subscribe(
1728                    "repair-tasks".to_owned(),
1729                    "tasks".to_owned(),
1730                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1731                    None,
1732                )
1733                .expect("subscribe");
1734            first
1735                .mutate(vec![Mutation::Upsert {
1736                    table: "tasks".to_owned(),
1737                    values: Map::from_iter([
1738                        ("id".to_owned(), Value::from("offline-row")),
1739                        ("project_id".to_owned(), Value::from("p1")),
1740                    ]),
1741                    base_version: None,
1742                }])
1743                .expect("queue offline work");
1744            assert_eq!(
1745                first
1746                    .rebootstrap_local_data(&LocalDataRebootstrapInput {
1747                        rebootstrap_id: "restart-receipt".to_owned(),
1748                    })
1749                    .expect("first rebootstrap"),
1750                LocalDataRebootstrapResult {
1751                    already_applied: false,
1752                    retained_commits: 1,
1753                    reset_subscriptions: 1,
1754                }
1755            );
1756        }
1757
1758        let mut reopened = SyncClient::open_path(
1759            "repair-restart-client".to_owned(),
1760            &schema,
1761            ClientLimits::default(),
1762            path_string,
1763        )
1764        .expect("reopen");
1765        reopened.drain_change_batches();
1766        reopened.drain_sync_intents();
1767        assert_eq!(
1768            reopened
1769                .rebootstrap_local_data(&LocalDataRebootstrapInput {
1770                    rebootstrap_id: "restart-receipt".to_owned(),
1771                })
1772                .expect("receipt replay"),
1773            LocalDataRebootstrapResult {
1774                already_applied: true,
1775                retained_commits: 1,
1776                reset_subscriptions: 1,
1777            }
1778        );
1779        assert!(reopened.drain_change_batches().is_empty());
1780        assert!(reopened.drain_sync_intents().is_empty());
1781        drop(reopened);
1782        std::fs::remove_file(path).expect("remove temp database");
1783    }
1784
1785    #[test]
1786    fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
1787        let mut malformed = client();
1788        malformed
1789            .subscribe(
1790                "repair-tasks".to_owned(),
1791                "tasks".to_owned(),
1792                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1793                None,
1794            )
1795            .expect("subscribe");
1796        malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
1797        malformed.drain_change_batches();
1798        malformed.drain_sync_intents();
1799        let malformed_error = malformed
1800            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1801                rebootstrap_id: "malformed".to_owned(),
1802            })
1803            .expect_err("malformed receipt must fail closed");
1804        assert_eq!(
1805            malformed_error,
1806            "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1807        );
1808        assert!(!malformed.upgrading());
1809        assert_eq!(
1810            malformed
1811                .subscription_state("repair-tasks")
1812                .expect("unchanged subscription")
1813                .cursor,
1814            -1
1815        );
1816        assert!(malformed.drain_change_batches().is_empty());
1817        assert!(malformed.drain_sync_intents().is_empty());
1818
1819        let mut unreadable = client();
1820        unreadable
1821            .conn
1822            .execute(
1823                "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
1824                rusqlite::params!["server-row", "p1"],
1825            )
1826            .expect("seed visible row");
1827        unreadable
1828            .conn
1829            .execute("DROP TABLE _syncular_meta", [])
1830            .expect("break marker storage");
1831        let unreadable_error = unreadable
1832            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1833                rebootstrap_id: "unreadable".to_owned(),
1834            })
1835            .expect_err("unreadable marker storage must fail closed");
1836        assert_eq!(
1837            unreadable_error,
1838            "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
1839        );
1840        let visible_rows = unreadable
1841            .conn
1842            .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
1843            .expect("visible projection remains");
1844        assert_eq!(visible_rows, 1);
1845        assert!(!unreadable.upgrading());
1846        assert!(unreadable.drain_change_batches().is_empty());
1847        assert!(unreadable.drain_sync_intents().is_empty());
1848    }
1849
1850    #[test]
1851    fn local_rebootstrap_cannot_bypass_schema_floor() {
1852        let mut client = client();
1853        client.set_schema_floor(Some(SchemaFloor {
1854            required_schema_version: Some(2),
1855            latest_schema_version: Some(2),
1856        }));
1857        let error = client
1858            .rebootstrap_local_data(&LocalDataRebootstrapInput {
1859                rebootstrap_id: "blocked-floor".to_owned(),
1860            })
1861            .expect_err("schema floor must block repair");
1862        assert!(error.contains("cannot bypass an active schema-floor stop"));
1863    }
1864
1865    #[test]
1866    fn diego_applied_sparse_moves_stay_visible_until_pull_image_arrives() {
1867        let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
1868            "columns":[{"name":"id","type":"string","nullable":false},
1869                {"name":"project_id","type":"string","nullable":false},
1870                {"name":"starts_at_ms","type":"integer","nullable":false},
1871                {"name":"version","type":"integer","nullable":false}],
1872            "scopes":[{"pattern":"project:{project_id}"}]}]});
1873        let mut client =
1874            SyncClient::new("diego-ack-gap".into(), &schema, ClientLimits::default()).unwrap();
1875        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1876        client
1877            .write_base_row(
1878                "tasks",
1879                &vec![
1880                    Some(ColumnValue::String("t1".into())),
1881                    Some(ColumnValue::String("p1".into())),
1882                    Some(ColumnValue::Integer(100)),
1883                    Some(ColumnValue::Integer(1)),
1884                ],
1885                1,
1886            )
1887            .unwrap();
1888        client.rebuild_overlay();
1889        let first = client
1890            .patch(
1891                "tasks",
1892                "t1",
1893                Map::from_iter([
1894                    ("starts_at_ms".into(), json!(200)),
1895                    ("version".into(), json!(2)),
1896                ]),
1897                None,
1898            )
1899            .unwrap();
1900        let (_, first_request) = client.build_request(false);
1901        // This second edit happens while the first request is in flight.
1902        let second = client
1903            .patch(
1904                "tasks",
1905                "t1",
1906                Map::from_iter([
1907                    ("starts_at_ms".into(), json!(300)),
1908                    ("version".into(), json!(3)),
1909                ]),
1910                None,
1911            )
1912            .unwrap();
1913        let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
1914        for (id, seq, meta) in [
1915            (first, 2, first_request),
1916            (second, 3, client.build_request(false).1),
1917        ] {
1918            let response = Message {
1919                wire_version: WIRE_VERSION,
1920                msg_kind: MsgKind::Response,
1921                frames: vec![
1922                    Frame::RespHeader {
1923                        required_schema_version: None,
1924                        latest_schema_version: None,
1925                        log_epoch: Some("epoch-1".into()),
1926                        reset_required: Some(false),
1927                    },
1928                    Frame::PushResult {
1929                        client_commit_id: id,
1930                        status: PushStatus::Applied,
1931                        commit_seq: Some(seq),
1932                        results: vec![OpResult::Applied { op_index: 0 }],
1933                    },
1934                ],
1935            };
1936            assert!(matches!(
1937                client.process_response(&mut transport, response, &meta),
1938                SyncOutcome::Ok(_)
1939            ));
1940            let row = &client
1941                .query("SELECT starts_at_ms, version FROM tasks WHERE id='t1'", &[])
1942                .unwrap()[0];
1943            assert_eq!(
1944                row["starts_at_ms"],
1945                json!(300),
1946                "an applied ACK without a pull image must not expose the old base"
1947            );
1948            assert_eq!(
1949                row["version"],
1950                json!(3),
1951                "the queued domain version must remain monotonic"
1952            );
1953        }
1954    }
1955
1956    #[test]
1957    fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
1958        let mut client = client();
1959        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1960        const COMMIT_COUNT: usize = 32;
1961
1962        for index in 0..COMMIT_COUNT {
1963            client
1964                .mutate(vec![Mutation::Upsert {
1965                    table: "tasks".to_owned(),
1966                    values: Map::from_iter([
1967                        ("id".to_owned(), Value::from(format!("task-{index}"))),
1968                        ("project_id".to_owned(), Value::from("project-1")),
1969                    ]),
1970                    base_version: None,
1971                }])
1972                .expect("queue commit");
1973        }
1974
1975        let (_, request_meta) = client.build_request(false);
1976        assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
1977        let mut frames = vec![Frame::RespHeader {
1978            required_schema_version: None,
1979            latest_schema_version: None,
1980            log_epoch: Some("epoch-1".to_owned()),
1981            reset_required: Some(false),
1982        }];
1983        frames.extend(request_meta.pushed_ids.iter().enumerate().map(
1984            |(index, client_commit_id)| Frame::PushResult {
1985                client_commit_id: client_commit_id.clone(),
1986                status: PushStatus::Applied,
1987                commit_seq: Some(index as i64 + 1),
1988                results: vec![OpResult::Applied { op_index: 0 }],
1989            },
1990        ));
1991        let response = Message {
1992            wire_version: WIRE_VERSION,
1993            msg_kind: MsgKind::Response,
1994            frames,
1995        };
1996        let mut transport =
1997            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
1998
1999        client.overlay_rebuild_count.set(0);
2000        client.outcome_prune_count.set(0);
2001        let outcome = client.process_response(&mut transport, response, &request_meta);
2002        assert!(matches!(outcome, SyncOutcome::Ok(_)));
2003        assert!(client.pending_commit_ids().is_empty());
2004        assert_eq!(
2005            client.overlay_rebuild_count.get(),
2006            1,
2007            "one response must reconcile its acknowledged commits with one overlay rebuild"
2008        );
2009        assert_eq!(
2010            client.outcome_prune_count.get(),
2011            1,
2012            "one response must enforce outcome retention once"
2013        );
2014    }
2015
2016    #[test]
2017    fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
2018        let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
2019            "columns": [
2020                {"name":"id", "type":"string", "nullable":false},
2021                {"name":"project_id", "type":"string", "nullable":false},
2022                {"name":"title", "type":"string", "nullable":false}
2023            ], "scopes": [{"pattern":"project:{project_id}"}],
2024            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
2025            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
2026        }] });
2027        let mut incremental =
2028            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
2029        let mut reference =
2030            SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
2031        let tables = vec!["tasks".to_owned()];
2032        for index in 0..32 {
2033            let id = format!("task-{}", index % 8);
2034            let values = Map::from_iter([
2035                ("id".to_owned(), Value::from(id.clone())),
2036                (
2037                    "project_id".to_owned(),
2038                    Value::from(format!("p{}", index % 2)),
2039                ),
2040                ("title".to_owned(), Value::from(format!("needle{index}"))),
2041            ]);
2042            let payload = encode_row_json(
2043                incremental.schema.table("tasks").unwrap(),
2044                &id,
2045                &values,
2046                &incremental.encryption,
2047            )
2048            .unwrap();
2049            let change = ssp2::model::Change {
2050                table_index: 0,
2051                row_id: id,
2052                op: if index % 5 == 0 {
2053                    Op::Delete
2054                } else {
2055                    Op::Upsert
2056                },
2057                row_version: Some(index + 1),
2058                scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
2059                row: Some(payload),
2060            };
2061            reference.overlay_dirty.set(true);
2062            for instance in [&mut incremental, &mut reference] {
2063                instance.begin_observation("mirror_test").unwrap();
2064                let mut batch = ChangeAccumulator::default();
2065                instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
2066                instance
2067                    .apply_commit_changes(&tables, std::slice::from_ref(&change))
2068                    .unwrap();
2069                instance.rebuild_overlay_if_dirty();
2070                instance.finish_observation("mirror_test", batch).unwrap();
2071            }
2072            for sql in [
2073                "SELECT * FROM tasks ORDER BY id",
2074                "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
2075            ] {
2076                assert_eq!(
2077                    incremental.query(sql, &[]).unwrap(),
2078                    reference.query(sql, &[]).unwrap()
2079                );
2080            }
2081            assert_eq!(incremental.local_revision(), reference.local_revision());
2082            assert_eq!(
2083                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2084                serde_json::to_value(reference.drain_change_batches()).unwrap()
2085            );
2086        }
2087        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
2088        assert_eq!(reference.overlay_rebuild_count.get(), 32);
2089    }
2090
2091    #[test]
2092    fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
2093        let cases = [
2094            (
2095                "string",
2096                vec![
2097                    json!("1"),
2098                    json!("01"),
2099                    json!(""),
2100                    json!("nul\u{0}id"),
2101                    json!("é"),
2102                ],
2103            ),
2104            (
2105                "integer",
2106                vec![
2107                    json!(i64::MIN),
2108                    json!(-1),
2109                    json!(0),
2110                    json!(1),
2111                    json!(i64::MAX),
2112                ],
2113            ),
2114            ("boolean", vec![json!(false), json!(true)]),
2115            ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
2116        ];
2117        for (kind, ids) in cases {
2118            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2119                "columns":[{"name":"id","type":kind,"nullable":false},
2120                    {"name":"project_id","type":"string","nullable":false}],
2121                "scopes":[{"pattern":"project:{project_id}"}]}]});
2122            let mut client =
2123                SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
2124            for (index, id) in ids.iter().enumerate() {
2125                let table = client.schema.table("tasks").unwrap();
2126                client
2127                    .write_base_row(
2128                        "tasks",
2129                        &vec![
2130                            json_to_column_value(&table.columns[0], Some(id)).unwrap(),
2131                            Some(ColumnValue::String(format!("p{index}"))),
2132                        ],
2133                        1,
2134                    )
2135                    .unwrap();
2136            }
2137            client.rebuild_overlay_if_dirty();
2138            for base in [true, false] {
2139                let full = if base {
2140                    base_table("tasks")
2141                } else {
2142                    visible_table("tasks")
2143                };
2144                let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
2145                for row_id in [
2146                    "1",
2147                    "01",
2148                    "1.0",
2149                    "1.0000000000000002",
2150                    "+1",
2151                    "1e0",
2152                    " 1",
2153                    "-1",
2154                    "0",
2155                    "9223372036854775807",
2156                    "-9223372036854775808",
2157                    "true",
2158                    "false",
2159                    "",
2160                    "nul\u{0}id",
2161                    "é",
2162                    "null",
2163                    "{\"a\":1}",
2164                ] {
2165                    let old_sql =
2166                        format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
2167                    let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
2168                    let old = client
2169                        .conn
2170                        .prepare(&old_sql)
2171                        .unwrap()
2172                        .query_map([row_id], |row| row.get::<_, String>(0))
2173                        .unwrap()
2174                        .collect::<Result<Vec<_>, _>>()
2175                        .unwrap();
2176                    let new = client
2177                        .conn
2178                        .prepare_cached(&new_sql)
2179                        .unwrap()
2180                        .query_map([row_id], |row| row.get::<_, String>(0))
2181                        .unwrap()
2182                        .collect::<Result<Vec<_>, _>>()
2183                        .unwrap();
2184                    assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
2185                    let mut batch = ChangeAccumulator::default();
2186                    assert_eq!(
2187                        client.record_row_scopes(&mut batch, "tasks", row_id, base),
2188                        !old.is_empty()
2189                    );
2190                    let expected = old.first().map(|scope| {
2191                        BTreeMap::from([(
2192                            "tasks".to_owned(),
2193                            Some(BTreeSet::from([format!("project:{scope}")])),
2194                        )])
2195                    });
2196                    assert_eq!(batch.tables, expected.unwrap_or_default());
2197                    client
2198                        .conn
2199                        .execute_batch("SAVEPOINT lookup_delete")
2200                        .unwrap();
2201                    if base {
2202                        client.delete_base_row("tasks", row_id).unwrap();
2203                    } else {
2204                        client.apply_outbox_ops(&[OutboxOp {
2205                            upsert: false,
2206                            table: "tasks".into(),
2207                            row_id: row_id.into(),
2208                            base_version: None,
2209                            values: None,
2210                        }]);
2211                    }
2212                    let remaining: i64 = client
2213                        .conn
2214                        .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
2215                            row.get(0)
2216                        })
2217                        .unwrap();
2218                    assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
2219                    client
2220                        .conn
2221                        .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
2222                        .unwrap();
2223                    client.overlay_dirty.set(false);
2224                }
2225                for (predicate, expected) in [
2226                    ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
2227                    (predicate, "SEARCH"),
2228                ] {
2229                    let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
2230                    let details = client
2231                        .conn
2232                        .prepare(&sql)
2233                        .unwrap()
2234                        .query_map(["1"], |row| row.get::<_, String>(3))
2235                        .unwrap()
2236                        .collect::<Result<Vec<_>, _>>()
2237                        .unwrap();
2238                    assert!(
2239                        details.iter().any(|detail| detail.contains(expected)),
2240                        "{kind}: {details:?}"
2241                    );
2242                }
2243            }
2244        }
2245    }
2246
2247    #[test]
2248    fn primary_keys_reject_every_ineligible_column_type() {
2249        // §2.4: a `rowId` is a string, so a primary key type whose string form
2250        // differs per renderer, or cannot be reproduced by local storage
2251        // comparison, is a schema error rather than a silent lookup miss.
2252        for ty in ["float", "bytes", "crdt", "blob_ref"] {
2253            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2254                "columns":[{"name":"id","type":ty,"nullable":false},
2255                    {"name":"project_id","type":"string","nullable":false}],
2256                "scopes":[{"pattern":"project:{project_id}"}]}]});
2257            let error =
2258                match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
2259                    Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
2260                    Err(error) => error,
2261                };
2262            assert!(
2263                error.contains("has an ineligible column type (§2.4)"),
2264                "{ty}: {error}"
2265            );
2266        }
2267        for ty in ["string", "integer", "boolean", "json"] {
2268            let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2269                "columns":[{"name":"id","type":ty,"nullable":false},
2270                    {"name":"project_id","type":"string","nullable":false}],
2271                "scopes":[{"pattern":"project:{project_id}"}]}]});
2272            SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
2273                .unwrap_or_else(|error| panic!("{ty}: {error}"));
2274        }
2275    }
2276
2277    #[test]
2278    fn pending_row_reconciliation_matches_full_fifo_replay() {
2279        for unique in [false, true] {
2280            let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
2281                "name": name, "primaryKey": "id", "columns": [
2282                    {"name":"id", "type":"string", "nullable":false},
2283                    {"name":"project_id", "type":"string", "nullable":false},
2284                    {"name":"title", "type":"string", "nullable":false}],
2285                "scopes": [{"pattern":"project:{project_id}"}],
2286                "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
2287                "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
2288            })))});
2289            let mut incremental =
2290                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
2291            let mut reference =
2292                SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
2293            let tables = vec!["tasks".to_owned(), "notes".to_owned()];
2294            for instance in [&mut incremental, &mut reference] {
2295                for table in &tables {
2296                    for index in 0..12 {
2297                        instance
2298                            .write_base_row(
2299                                table,
2300                                &vec![
2301                                    Some(ColumnValue::String(format!("row{index}"))),
2302                                    Some(ColumnValue::String("p0".into())),
2303                                    Some(ColumnValue::String(format!("base{index}"))),
2304                                ],
2305                                1,
2306                            )
2307                            .unwrap();
2308                    }
2309                }
2310                instance.rebuild_overlay_if_dirty();
2311            }
2312            for index in 0..80 {
2313                let mutations = (0..3)
2314                    .map(|offset| {
2315                        let table = tables[(index + offset) % 2].clone();
2316                        let id = format!("row{}", (index + offset) % 16);
2317                        if (index + offset) % 7 == 0 {
2318                            Mutation::Delete {
2319                                table,
2320                                row_id: id,
2321                                base_version: None,
2322                            }
2323                        } else {
2324                            Mutation::Upsert {
2325                                table,
2326                                values: Map::from_iter([
2327                                    ("id".into(), Value::from(id)),
2328                                    ("project_id".into(), Value::from(format!("p{}", index % 3))),
2329                                    (
2330                                        "title".into(),
2331                                        Value::from(format!("needle{}", (index + offset) % 16)),
2332                                    ),
2333                                ]),
2334                                base_version: None,
2335                            }
2336                        }
2337                    })
2338                    .collect::<Vec<_>>();
2339                for instance in [&mut incremental, &mut reference] {
2340                    instance.mutate(mutations.clone()).unwrap();
2341                }
2342                // Most frames touch only the table without unique constraints.
2343                // Mixed frames must retain full replay for both tables.
2344                let changes = (0..3)
2345                    .map(|offset| {
2346                        let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
2347                        let id = format!("row{}", (index + offset) % 16);
2348                        let values = Map::from_iter([
2349                            ("id".into(), Value::from(id.clone())),
2350                            (
2351                                "project_id".into(),
2352                                Value::from(format!("p{}", (index + 1) % 3)),
2353                            ),
2354                            (
2355                                "title".into(),
2356                                Value::from(format!("remote{index}_{offset}")),
2357                            ),
2358                        ]);
2359                        ssp2::model::Change {
2360                            table_index: table_index as u16,
2361                            row_id: id.clone(),
2362                            op: if (index + offset) % 5 == 0 {
2363                                Op::Delete
2364                            } else {
2365                                Op::Upsert
2366                            },
2367                            row_version: Some(index as i64 + 2),
2368                            scopes: vec![],
2369                            row: Some(
2370                                encode_row_json(
2371                                    incremental.schema.table(&tables[table_index]).unwrap(),
2372                                    &id,
2373                                    &values,
2374                                    &incremental.encryption,
2375                                )
2376                                .unwrap(),
2377                            ),
2378                        }
2379                    })
2380                    .collect::<Vec<_>>();
2381                reference.overlay_dirty.set(true);
2382                incremental
2383                    .apply_commit_frame(&tables, &changes, None)
2384                    .unwrap();
2385                reference
2386                    .apply_commit_frame(&tables, &changes, None)
2387                    .unwrap();
2388                for table in &tables {
2389                    for sql in [format!("SELECT * FROM {table} ORDER BY id"),
2390                        format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
2391                        format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
2392                        format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
2393                        assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
2394                    }
2395                }
2396                assert_eq!(incremental.local_revision(), reference.local_revision());
2397                assert_eq!(
2398                    serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2399                    serde_json::to_value(reference.drain_change_batches()).unwrap()
2400                );
2401            }
2402            assert_eq!(
2403                incremental.overlay_rebuild_count.get(),
2404                if unique { 81 } else { 21 }
2405            );
2406            assert_eq!(reference.overlay_rebuild_count.get(), 81);
2407        }
2408    }
2409
2410    #[test]
2411    fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
2412        let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
2413            "columns":[{"name":"id", "type":"string", "nullable":false},
2414                {"name":"title", "type":"string", "nullable":false}],
2415            "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
2416        let mut client =
2417            SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
2418        for (id, title) in [("a", "original"), ("b", "occupied")] {
2419            client
2420                .write_base_row(
2421                    "tasks",
2422                    &vec![
2423                        Some(ColumnValue::String(id.into())),
2424                        Some(ColumnValue::String(title.into())),
2425                    ],
2426                    1,
2427                )
2428                .unwrap();
2429        }
2430        client.rebuild_overlay_if_dirty();
2431        let pending = client
2432            .mutate(vec![Mutation::Upsert {
2433                table: "tasks".into(),
2434                values: Map::from_iter([
2435                    ("id".into(), Value::from("a")),
2436                    ("title".into(), Value::from("available")),
2437                ]),
2438                base_version: None,
2439            }])
2440            .unwrap();
2441        assert_eq!(
2442            client
2443                .conn
2444                .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2445                    .get::<_, String>(
2446                    0
2447                ))
2448                .unwrap(),
2449            "available"
2450        );
2451        for (title, expected) in [("available", "original"), ("occupied", "available")] {
2452            let change = ssp2::model::Change {
2453                table_index: 0,
2454                row_id: "b".into(),
2455                op: Op::Upsert,
2456                row_version: Some(2),
2457                scopes: vec![],
2458                row: Some(
2459                    encode_row_json(
2460                        client.schema.table("tasks").unwrap(),
2461                        "b",
2462                        &Map::from_iter([
2463                            ("id".into(), Value::from("b")),
2464                            ("title".into(), Value::from(title)),
2465                        ]),
2466                        &client.encryption,
2467                    )
2468                    .unwrap(),
2469                ),
2470            };
2471            client
2472                .apply_commit_frame(&["tasks".into()], &[change], None)
2473                .unwrap();
2474            assert_eq!(
2475                client
2476                    .conn
2477                    .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
2478                        .get::<_, String>(
2479                        0
2480                    ))
2481                    .unwrap(),
2482                expected
2483            );
2484            assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
2485        }
2486        assert_eq!(client.overlay_rebuild_count.get(), 3);
2487    }
2488
2489    fn measure_scoped_overlay(unchanged_rows: i64, rounds: i64) -> Vec<u128> {
2490        let schema = json!({ "version": 1, "tables": [
2491            { "name":"tasks", "primaryKey":"id", "columns":[
2492                {"name":"id","type":"string","nullable":false},
2493                {"name":"project_id","type":"string","nullable":false}],
2494                "scopes":[{"pattern":"project:{project_id}"}] },
2495            { "name":"catalogue", "primaryKey":"id", "columns":[
2496                {"name":"id","type":"string","nullable":false},
2497                {"name":"project_id","type":"string","nullable":false},
2498                {"name":"title","type":"string","nullable":false}],
2499                "scopes":[{"pattern":"project:{project_id}"}],
2500                "ftsIndexes":[{"name":"catalogue_fts","columns":["title"],"tokenize":"unicode61"}] }
2501        ] });
2502        let mut client =
2503            SyncClient::new("overlay-scope".into(), &schema, ClientLimits::default()).unwrap();
2504        if unchanged_rows > 0 {
2505            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();
2506        }
2507        client.rebuild_overlay();
2508        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();
2509        let mut elapsed = Vec::new();
2510        for round in 1..=rounds {
2511            let pending = client
2512                .mutate(vec![Mutation::Upsert {
2513                    table: "tasks".into(),
2514                    values: Map::from_iter([
2515                        ("id".into(), json!("t1")),
2516                        ("project_id".into(), json!("p1")),
2517                    ]),
2518                    base_version: None,
2519                }])
2520                .unwrap();
2521            let started = std::time::Instant::now();
2522            client
2523                .write_base_row(
2524                    "tasks",
2525                    &vec![
2526                        Some(ColumnValue::String("t1".into())),
2527                        Some(ColumnValue::String("p1".into())),
2528                    ],
2529                    round,
2530                )
2531                .unwrap();
2532            let mut report = SyncReport::default();
2533            client
2534                .handle_push_results(
2535                    &[Frame::PushResult {
2536                        client_commit_id: pending.clone(),
2537                        status: PushStatus::Applied,
2538                        commit_seq: Some(round),
2539                        results: vec![OpResult::Applied { op_index: 0 }],
2540                    }],
2541                    &HashSet::from([pending.as_str()]),
2542                    &HashMap::new(),
2543                    &mut report,
2544                    Some(&pending),
2545                )
2546                .unwrap();
2547            client.rebuild_overlay_if_dirty();
2548            elapsed.push(started.elapsed().as_nanos());
2549        }
2550        assert_eq!(
2551            client
2552                .conn
2553                .query_row("SELECT count(*) FROM audit", [], |r| r.get::<_, i64>(0))
2554                .unwrap(),
2555            0,
2556            "unchanged catalogue must never be copied"
2557        );
2558        assert_eq!(
2559            client
2560                .conn
2561                .query_row(
2562                    "SELECT count(*) FROM catalogue_fts WHERE catalogue_fts MATCH 'needle'",
2563                    [],
2564                    |r| r.get::<_, i64>(0)
2565                )
2566                .unwrap(),
2567            unchanged_rows
2568        );
2569        assert_eq!(
2570            client.query("SELECT * FROM tasks", &[]).unwrap()[0]["_syncular_version"],
2571            json!(rounds)
2572        );
2573        elapsed
2574    }
2575
2576    #[test]
2577    fn changed_overlay_leaves_large_unchanged_table_and_fts_untouched() {
2578        let elapsed = measure_scoped_overlay(100_000, 1);
2579        eprintln!(
2580            "table-scoped overlay with 100k unchanged rows: {} ns",
2581            elapsed[0]
2582        );
2583    }
2584
2585    #[test]
2586    #[ignore = "isolated performance measurement; run with the shared heavy-check lock"]
2587    fn benchmark_scoped_overlay() {
2588        for rows in [0, 100_000, 200_000] {
2589            let mut elapsed = measure_scoped_overlay(rows, 30);
2590            elapsed.sort_unstable();
2591            eprintln!(
2592                "{}",
2593                json!({"unchangedRows": rows, "rounds": elapsed.len(),
2594                "medianNs": elapsed[elapsed.len()/2], "minNs": elapsed[0],
2595                "maxNs": elapsed[elapsed.len()-1], "unchangedDeletes": 0})
2596            );
2597        }
2598    }
2599
2600    #[test]
2601    fn released_context_discards_in_flight_ack_and_server_rows() {
2602        for barrier in ["preflight", "purge", "subscription"] {
2603            let mut client = client();
2604            client.set_meta(LOG_EPOCH_KEY, "epoch");
2605            client
2606                .subscribe(
2607                    "tasks".into(),
2608                    "tasks".into(),
2609                    vec![("project_id".into(), vec!["p1".into()])],
2610                    None,
2611                )
2612                .unwrap();
2613            let commit = client
2614                .mutate(vec![Mutation::Upsert {
2615                    table: "tasks".into(),
2616                    values: Map::from_iter([
2617                        ("id".into(), json!("local")),
2618                        ("project_id".into(), json!("p1")),
2619                    ]),
2620                    base_version: None,
2621                }])
2622                .unwrap();
2623            let prepared = client.prepare_sync_round(false).unwrap();
2624            let payload = encode_row_json(
2625                &client.schema.tables[0],
2626                "server",
2627                &Map::from_iter([
2628                    ("id".into(), json!("server")),
2629                    ("project_id".into(), json!("p1")),
2630                ]),
2631                &client.encryption,
2632            )
2633            .unwrap();
2634            let completed = crate::CompletedSyncRound {
2635                prepared,
2636                exchange: crate::round::ExchangeResult::Reply {
2637                    transport_failed: false,
2638                    downloads: Default::default(),
2639                    response: Ok(Message {
2640                        wire_version: WIRE_VERSION,
2641                        msg_kind: MsgKind::Response,
2642                        frames: vec![
2643                            Frame::RespHeader {
2644                                required_schema_version: None,
2645                                latest_schema_version: None,
2646                                log_epoch: Some("epoch".into()),
2647                                reset_required: Some(false),
2648                            },
2649                            Frame::PushResult {
2650                                client_commit_id: commit.clone(),
2651                                status: PushStatus::Applied,
2652                                commit_seq: Some(1),
2653                                results: vec![OpResult::Applied { op_index: 0 }],
2654                            },
2655                            Frame::SubStart {
2656                                id: "tasks".into(),
2657                                status: SubStatus::Active,
2658                                reason_code: String::new(),
2659                                effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
2660                                bootstrap: true,
2661                            },
2662                            Frame::Commit {
2663                                commit_seq: 1,
2664                                created_at_ms: 0,
2665                                actor_id: "actor".into(),
2666                                tables: vec!["tasks".into()],
2667                                changes: vec![ssp2::model::Change {
2668                                    table_index: 0,
2669                                    row_id: "server".into(),
2670                                    op: Op::Upsert,
2671                                    row_version: Some(1),
2672                                    scopes: vec![("project_id".into(), "p1".into())],
2673                                    row: Some(payload),
2674                                }],
2675                            },
2676                            Frame::SubEnd {
2677                                next_cursor: 1,
2678                                bootstrap_state: None,
2679                            },
2680                        ],
2681                    }),
2682                },
2683            };
2684            match barrier {
2685                "preflight" => client.begin_security_preflight(),
2686                "purge" => {
2687                    client
2688                        .purge_local_data(&LocalDataPurgeInput {
2689                            purge_id: "pending-round".into(),
2690                            targets: vec![LocalDataPurgeTarget {
2691                                table: "tasks".into(),
2692                                selectors: BTreeMap::from([(
2693                                    "project_id".into(),
2694                                    vec!["p1".into()],
2695                                )]),
2696                            }],
2697                        })
2698                        .unwrap();
2699                }
2700                "subscription" => {
2701                    client.unsubscribe("tasks");
2702                    client
2703                        .subscribe(
2704                            "tasks".into(),
2705                            "tasks".into(),
2706                            vec![("project_id".into(), vec!["p2".into()])],
2707                            None,
2708                        )
2709                        .unwrap();
2710                }
2711                _ => unreachable!(),
2712            }
2713            let pending_before_reply = client.pending_commit_ids();
2714            let revision = client.local_revision();
2715            let applied = client.apply_sync_round(completed);
2716            assert!(
2717                matches!(applied, crate::AppliedSyncRound::Complete { outcome: SyncOutcome::Failed { ref error_code, .. }, ref controls, .. } if error_code == "client.round_cancelled" && controls.is_empty())
2718            );
2719            assert_eq!(client.local_revision(), revision);
2720            assert!(client
2721                .conn
2722                .query_row(
2723                    "SELECT NOT EXISTS(SELECT 1 FROM tasks WHERE id='server')",
2724                    [],
2725                    |r| r.get::<_, bool>(0)
2726                )
2727                .unwrap());
2728            assert_eq!(client.pending_commit_ids(), pending_before_reply);
2729        }
2730    }
2731
2732    #[test]
2733    fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
2734        for failure in ["visible", "revision", "commit"] {
2735            let mut client = client();
2736            let id = client
2737                .mutate(vec![Mutation::Upsert {
2738                    table: "tasks".into(),
2739                    values: Map::from_iter([
2740                        ("id".into(), Value::from("t1")),
2741                        ("project_id".into(), Value::from("pending")),
2742                    ]),
2743                    base_version: None,
2744                }])
2745                .unwrap();
2746            client.drain_change_batches();
2747            let revision = client.local_revision();
2748            let before = client.query("SELECT * FROM tasks", &[]).unwrap();
2749            let trigger = match failure {
2750                "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
2751                "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"),
2752                _ => "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(),
2753            };
2754            client.conn.execute_batch(&trigger).unwrap();
2755            let change = ssp2::model::Change {
2756                table_index: 0,
2757                row_id: "t1".into(),
2758                op: Op::Upsert,
2759                row_version: Some(2),
2760                scopes: vec![],
2761                row: Some(
2762                    encode_row_json(
2763                        client.schema.table("tasks").unwrap(),
2764                        "t1",
2765                        &Map::from_iter([
2766                            ("id".into(), Value::from("t1")),
2767                            ("project_id".into(), Value::from("remote")),
2768                        ]),
2769                        &client.encryption,
2770                    )
2771                    .unwrap(),
2772                ),
2773            };
2774            assert!(client
2775                .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), None)
2776                .is_err());
2777            assert!(client.conn.is_autocommit());
2778            assert!(!client.overlay_dirty.get());
2779            assert_eq!(client.local_revision(), revision);
2780            assert!(client.drain_change_batches().is_empty());
2781            assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
2782            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
2783            assert!(client
2784                .query("SELECT * FROM _syncular_base_tasks", &[])
2785                .unwrap()
2786                .is_empty());
2787            client
2788                .conn
2789                .execute_batch("DROP TRIGGER fail_pending")
2790                .unwrap();
2791            client
2792                .apply_commit_frame(&["tasks".into()], &[change], None)
2793                .unwrap();
2794            assert_eq!(client.local_revision(), revision + 1);
2795            assert_eq!(client.pending_commit_ids(), vec![id]);
2796            // The overlay keeps the pending write visible; the row now carries
2797            // the server row's version it is layered on (§7.1).
2798            let mut expected = before[0].clone();
2799            expected.insert("_syncular_version".to_owned(), Value::from(2));
2800            assert_eq!(
2801                client.query("SELECT * FROM tasks", &[]).unwrap(),
2802                vec![expected]
2803            );
2804            assert_eq!(
2805                client
2806                    .conn
2807                    .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
2808                        .get::<_, String>(0))
2809                    .unwrap(),
2810                "remote"
2811            );
2812        }
2813    }
2814
2815    #[test]
2816    fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
2817        let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
2818            "columns": [
2819                {"name":"id", "type":"string", "nullable":false},
2820                {"name":"project_id", "type":"string", "nullable":false},
2821                {"name":"title", "type":"string", "nullable":false}],
2822            "scopes": [{"pattern":"project:{project_id}"}],
2823            "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
2824            "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
2825        let mut incremental =
2826            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2827        let mut reference =
2828            SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
2829        for instance in [&mut incremental, &mut reference] {
2830            for index in 0..16 {
2831                instance
2832                    .write_base_row(
2833                        "tasks",
2834                        &vec![
2835                            Some(ColumnValue::String(format!("task{index}"))),
2836                            Some(ColumnValue::String("p0".to_owned())),
2837                            Some(ColumnValue::String(format!("base{index}"))),
2838                        ],
2839                        1,
2840                    )
2841                    .unwrap();
2842            }
2843            instance.rebuild_overlay_if_dirty();
2844            instance.overlay_rebuild_count.set(0);
2845        }
2846        for index in 0..100 {
2847            let mutations = (0..1 + index % 4)
2848                .map(|offset| {
2849                    let id = format!("task{}", (index + offset) % 24);
2850                    if (index + offset) % 7 == 0 {
2851                        Mutation::Delete {
2852                            table: "tasks".to_owned(),
2853                            row_id: id,
2854                            base_version: None,
2855                        }
2856                    } else {
2857                        Mutation::Upsert {
2858                            table: "tasks".to_owned(),
2859                            values: Map::from_iter([
2860                                ("id".to_owned(), Value::from(id)),
2861                                (
2862                                    "project_id".to_owned(),
2863                                    Value::from(format!("p{}", index % 2)),
2864                                ),
2865                                (
2866                                    "title".to_owned(),
2867                                    Value::from(format!("needle{}", (index + offset) % 24)),
2868                                ),
2869                            ]),
2870                            base_version: None,
2871                        }
2872                    }
2873                })
2874                .collect::<Vec<_>>();
2875            reference.overlay_dirty.set(true);
2876            incremental.mutate(mutations.clone()).unwrap();
2877            reference.mutate(mutations).unwrap();
2878            for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
2879                "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
2880                assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
2881            }
2882            assert_eq!(incremental.local_revision(), reference.local_revision());
2883            assert_eq!(
2884                serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2885                serde_json::to_value(reference.drain_change_batches()).unwrap()
2886            );
2887        }
2888        assert_eq!(incremental.outbox.len(), 100);
2889        assert_eq!(incremental.overlay_rebuild_count.get(), 0);
2890        assert_eq!(reference.overlay_rebuild_count.get(), 100);
2891    }
2892
2893    #[test]
2894    fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
2895        for fail_at in ["outbox", "revision"] {
2896            let mut client = client();
2897            let values = Map::from_iter([
2898                ("id".to_owned(), Value::from("t1")),
2899                ("project_id".to_owned(), Value::from("p1")),
2900            ]);
2901            let mutation = Mutation::Upsert {
2902                table: "tasks".to_owned(),
2903                values,
2904                base_version: None,
2905            };
2906            let trigger = if fail_at == "outbox" {
2907                "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
2908            } else {
2909                format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
2910            };
2911            client.conn.execute_batch(&trigger).unwrap();
2912            let revision = client.local_revision();
2913            assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
2914            assert_eq!(client.local_revision(), revision);
2915            assert!(client.outbox.is_empty());
2916            assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2917            assert_eq!(
2918                client
2919                    .conn
2920                    .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
2921                        .get::<_, i64>(0))
2922                    .unwrap(),
2923                0
2924            );
2925            assert!(client.drain_change_batches().is_empty());
2926            assert!(!client.overlay_dirty.get());
2927            client
2928                .conn
2929                .execute_batch("DROP TRIGGER fail_append")
2930                .unwrap();
2931            client.mutate(vec![mutation]).unwrap();
2932            assert_eq!(client.outbox.len(), 1);
2933            assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
2934        }
2935    }
2936
2937    #[test]
2938    fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
2939        let mut client = client();
2940        let tables = vec!["tasks".to_owned(), "missing".to_owned()];
2941        let values = Map::from_iter([
2942            ("id".to_owned(), Value::from("t1")),
2943            ("project_id".to_owned(), Value::from("p1")),
2944        ]);
2945        let payload = encode_row_json(
2946            client.schema.table("tasks").unwrap(),
2947            "t1",
2948            &values,
2949            &client.encryption,
2950        )
2951        .unwrap();
2952        let first = ssp2::model::Change {
2953            table_index: 0,
2954            row_id: "t1".to_owned(),
2955            op: Op::Upsert,
2956            row_version: Some(1),
2957            scopes: vec![("project_id".to_owned(), "p1".to_owned())],
2958            row: Some(payload),
2959        };
2960        let mut invalid = first.clone();
2961        invalid.table_index = 1;
2962        let revision = client.local_revision();
2963        client.begin_observation("failed_mirror").unwrap();
2964        assert!(client
2965            .apply_commit_changes(&tables, &[first, invalid])
2966            .is_err());
2967        client.rollback_observation("failed_mirror");
2968        client.rebuild_overlay_if_dirty();
2969        assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2970        assert_eq!(
2971            client
2972                .conn
2973                .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
2974                    .get::<_, i64>(
2975                    0
2976                ))
2977                .unwrap(),
2978            0
2979        );
2980        assert_eq!(client.local_revision(), revision);
2981        assert!(client.drain_change_batches().is_empty());
2982    }
2983
2984    #[test]
2985    fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
2986        for failure in ["journal", "revision", "commit", "later-frame"] {
2987            let mut client = client();
2988            client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2989            for id in ["first", "second"] {
2990                client
2991                    .mutate(vec![Mutation::Upsert {
2992                        table: "tasks".to_owned(),
2993                        values: Map::from_iter([
2994                            ("id".to_owned(), Value::from(id)),
2995                            ("project_id".to_owned(), Value::from("p1")),
2996                        ]),
2997                        base_version: None,
2998                    }])
2999                    .unwrap();
3000            }
3001            let (_, meta) = client.build_request(false);
3002            // A malicious or stale response must not drain an unsent local commit.
3003            client
3004                .mutate(vec![Mutation::Upsert {
3005                    table: "tasks".to_owned(),
3006                    values: Map::from_iter([
3007                        ("id".to_owned(), Value::from("later")),
3008                        ("project_id".to_owned(), Value::from("p1")),
3009                    ]),
3010                    base_version: None,
3011                }])
3012                .unwrap();
3013            let ids = client.pending_commit_ids();
3014            let rows = client
3015                .query("SELECT * FROM tasks ORDER BY id", &[])
3016                .unwrap();
3017            let revision = client.local_revision();
3018            client.drain_change_batches();
3019            if failure != "later-frame" {
3020                client.conn.execute_batch(match failure {
3021                    "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",
3022                    "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",
3023                    _ => "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",
3024                }).unwrap();
3025            }
3026            let mut response = Message {
3027                wire_version: WIRE_VERSION,
3028                msg_kind: MsgKind::Response,
3029                frames: vec![Frame::RespHeader {
3030                    required_schema_version: None,
3031                    latest_schema_version: None,
3032                    log_epoch: Some("epoch-1".to_owned()),
3033                    reset_required: Some(false),
3034                }],
3035            };
3036            for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
3037                response.frames.push(Frame::PushResult {
3038                    client_commit_id: id.to_owned(),
3039                    status: PushStatus::Applied,
3040                    commit_seq: Some(1),
3041                    results: vec![OpResult::Applied { op_index: 0 }],
3042                });
3043            }
3044            if failure == "later-frame" {
3045                response.frames.push(Frame::Error {
3046                    code: "sync.invalid_request".to_owned(),
3047                    message: "later error".to_owned(),
3048                    category: "protocol".to_owned(),
3049                    retryable: false,
3050                    recommended_action: "retry".to_owned(),
3051                    details: None,
3052                });
3053            }
3054            let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3055            let outcome = client.process_response(&mut transport, response.clone(), &meta);
3056            assert!(
3057                matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == if failure == "later-frame" { "sync.invalid_request" } else { "client.outcome_persistence_failed" })
3058            );
3059            if failure == "later-frame" {
3060                assert_eq!(client.pending_commit_ids(), ids[2..]);
3061                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
3062                assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
3063                let batches = client.drain_change_batches();
3064                assert_eq!(batches.len(), 1);
3065                assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
3066                assert_eq!(client.local_revision(), revision + 1);
3067                response.frames.pop();
3068            } else {
3069                assert_eq!(client.pending_commit_ids(), ids);
3070                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
3071                assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
3072                assert_eq!(client.local_revision(), revision);
3073                assert!(client.drain_change_batches().is_empty());
3074                assert_eq!(
3075                    client
3076                        .query("SELECT * FROM tasks ORDER BY id", &[])
3077                        .unwrap(),
3078                    rows
3079                );
3080                client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
3081            }
3082            for frame in &mut response.frames {
3083                if let Frame::PushResult { status, .. } = frame {
3084                    *status = PushStatus::Cached;
3085                }
3086            }
3087            let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
3088            else {
3089                panic!("retry failed");
3090            };
3091            assert_eq!(
3092                report.applied,
3093                if failure == "later-frame" {
3094                    vec![]
3095                } else {
3096                    ids[..2].to_vec()
3097                }
3098            );
3099            assert_eq!(client.pending_commit_ids(), ids[2..]);
3100            let batches = client.drain_change_batches();
3101            assert_eq!(batches.len(), usize::from(failure != "later-frame"));
3102            if let Some(batch) = batches.first() {
3103                assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
3104            }
3105            assert_eq!(client.local_revision(), revision + 1);
3106        }
3107    }
3108
3109    #[test]
3110    fn failed_push_result_preserves_memory_disk_and_retry_identity() {
3111        for failure in ["revision", "journal", "commit"] {
3112            for status in ["applied", "cached", "rejected", "conflict"] {
3113                let mut client = client();
3114                client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3115                for id in ["first", "later"] {
3116                    client
3117                        .mutate(vec![Mutation::Upsert {
3118                            table: "tasks".to_owned(),
3119                            values: Map::from_iter([
3120                                ("id".to_owned(), Value::from(id)),
3121                                ("project_id".to_owned(), Value::from("p1")),
3122                            ]),
3123                            base_version: None,
3124                        }])
3125                        .unwrap();
3126                }
3127                let (_, meta) = client.build_request(false);
3128                let ids = client.pending_commit_ids();
3129                let rows = client
3130                    .query("SELECT * FROM tasks ORDER BY id", &[])
3131                    .unwrap();
3132                let revision = client.local_revision();
3133                client.drain_change_batches();
3134                let (push_status, results) = match status {
3135                    "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
3136                    "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
3137                    "conflict" => (
3138                        PushStatus::Rejected,
3139                        vec![OpResult::Conflict {
3140                            op_index: 0,
3141                            code: "sync.version_conflict".to_owned(),
3142                            message: "conflict".to_owned(),
3143                            server_version: 1,
3144                            server_row: encode_row_json(
3145                                client.schema.table("tasks").unwrap(),
3146                                "first",
3147                                &Map::from_iter([
3148                                    ("id".to_owned(), Value::from("first")),
3149                                    ("project_id".to_owned(), Value::from("p1")),
3150                                ]),
3151                                &client.encryption,
3152                            )
3153                            .unwrap(),
3154                            conflict_columns: Vec::new(),
3155                        }],
3156                    ),
3157                    _ => (
3158                        PushStatus::Rejected,
3159                        vec![OpResult::Error {
3160                            op_index: 0,
3161                            code: "sync.validation_failed".to_owned(),
3162                            message: "rejected".to_owned(),
3163                            retryable: false,
3164                        }],
3165                    ),
3166                };
3167                let response = Message {
3168                    wire_version: WIRE_VERSION,
3169                    msg_kind: MsgKind::Response,
3170                    frames: vec![
3171                        Frame::RespHeader {
3172                            required_schema_version: None,
3173                            latest_schema_version: None,
3174                            log_epoch: Some("epoch-1".to_owned()),
3175                            reset_required: Some(false),
3176                        },
3177                        Frame::PushResult {
3178                            client_commit_id: ids[0].clone(),
3179                            status: push_status,
3180                            commit_seq: Some(1),
3181                            results,
3182                        },
3183                    ],
3184                };
3185                client.conn.execute_batch(match failure {
3186                    "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
3187                    "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
3188                    _ => "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",
3189                }).unwrap();
3190                let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3191                let outcome = client.process_response(&mut transport, response.clone(), &meta);
3192                assert_eq!(
3193                    client.pending_commit_ids(),
3194                    ids,
3195                    "{failure}/{status}: pending identity"
3196                );
3197                assert_eq!(
3198                    client.local_revision(),
3199                    revision,
3200                    "{failure}/{status}: revision"
3201                );
3202                assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
3203                assert!(client.conflicts().is_empty());
3204                assert!(client.rejections().is_empty());
3205                assert!(client.drain_change_batches().is_empty());
3206                assert_eq!(
3207                    client
3208                        .query("SELECT * FROM tasks ORDER BY id", &[])
3209                        .unwrap(),
3210                    rows
3211                );
3212                assert!(
3213                    matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == "client.outcome_persistence_failed"),
3214                    "{failure}/{status}: failed round"
3215                );
3216                client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
3217                assert!(matches!(
3218                    client.process_response(&mut transport, response, &meta),
3219                    SyncOutcome::Ok(_)
3220                ));
3221                assert_eq!(client.pending_commit_ids(), ids[1..]);
3222                assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
3223                assert_eq!(client.drain_change_batches().len(), 1);
3224            }
3225        }
3226    }
3227
3228    #[test]
3229    fn mixed_push_results_reconcile_and_prune_once_per_response() {
3230        let mut client = client();
3231        client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3232        for index in 0..4 {
3233            client
3234                .mutate(vec![Mutation::Upsert {
3235                    table: "tasks".to_owned(),
3236                    values: Map::from_iter([
3237                        ("id".to_owned(), Value::from(format!("task-{index}"))),
3238                        ("project_id".to_owned(), Value::from("project-1")),
3239                    ]),
3240                    base_version: None,
3241                }])
3242                .expect("queue commit");
3243        }
3244
3245        let (_, request_meta) = client.build_request(false);
3246        let ids = &request_meta.pushed_ids;
3247        assert_eq!(ids.len(), 4);
3248        let response = Message {
3249            wire_version: WIRE_VERSION,
3250            msg_kind: MsgKind::Response,
3251            frames: vec![
3252                Frame::RespHeader {
3253                    required_schema_version: None,
3254                    latest_schema_version: None,
3255                    log_epoch: Some("epoch-1".to_owned()),
3256                    reset_required: Some(false),
3257                },
3258                Frame::PushResult {
3259                    client_commit_id: ids[0].clone(),
3260                    status: PushStatus::Applied,
3261                    commit_seq: Some(1),
3262                    results: vec![OpResult::Applied { op_index: 0 }],
3263                },
3264                Frame::PushResult {
3265                    client_commit_id: ids[1].clone(),
3266                    status: PushStatus::Cached,
3267                    commit_seq: Some(2),
3268                    results: vec![OpResult::Applied { op_index: 0 }],
3269                },
3270                Frame::PushResult {
3271                    client_commit_id: ids[2].clone(),
3272                    status: PushStatus::Rejected,
3273                    commit_seq: None,
3274                    results: vec![OpResult::Error {
3275                        op_index: 0,
3276                        code: "sync.validation_failed".to_owned(),
3277                        message: "rejected".to_owned(),
3278                        retryable: false,
3279                    }],
3280                },
3281                Frame::PushResult {
3282                    client_commit_id: ids[3].clone(),
3283                    status: PushStatus::Rejected,
3284                    commit_seq: None,
3285                    results: vec![OpResult::Error {
3286                        op_index: 0,
3287                        code: "sync.idempotency_cache_miss".to_owned(),
3288                        message: "retry".to_owned(),
3289                        retryable: true,
3290                    }],
3291                },
3292            ],
3293        };
3294        let mut transport =
3295            HostTransport::new_from_config(&json!({})).expect("no-network host transport");
3296
3297        client.overlay_rebuild_count.set(0);
3298        client.outcome_prune_count.set(0);
3299        let outcome = client.process_response(&mut transport, response, &request_meta);
3300        let SyncOutcome::Ok(report) = outcome else {
3301            panic!("mixed push-result response failed");
3302        };
3303
3304        assert_eq!(report.applied, ids[..2]);
3305        assert_eq!(report.rejected, ids[2..3]);
3306        assert_eq!(report.retryable, ids[3..4]);
3307        assert_eq!(client.pending_commit_ids(), ids[3..4]);
3308        assert_eq!(
3309            client
3310                .query("SELECT id FROM tasks ORDER BY id", &[])
3311                .expect("query visible overlay"),
3312            ["task-0", "task-1", "task-3"]
3313                .map(|id| Map::from_iter([("id".to_owned(), Value::from(id))]))
3314                .to_vec()
3315        );
3316        assert_eq!(client.overlay_rebuild_count.get(), 1);
3317        assert_eq!(client.outcome_prune_count.get(), 1);
3318    }
3319
3320    #[test]
3321    fn secondary_unique_collision_preserves_existing_synced_row() {
3322        let client = SyncClient::new(
3323            "unique-upsert-test".to_owned(),
3324            &json!({
3325                "version": 1,
3326                "tables": [{
3327                    "name": "tasks",
3328                    "primaryKey": "id",
3329                    "columns": [
3330                        { "name": "id", "type": "string", "nullable": false },
3331                        { "name": "project_id", "type": "string", "nullable": false },
3332                        { "name": "title", "type": "string", "nullable": false }
3333                    ],
3334                    "scopes": [{ "pattern": "project:{project_id}" }],
3335                    "indexes": [{
3336                        "name": "tasks_by_project_title",
3337                        "columns": ["project_id", "title"],
3338                        "unique": true
3339                    }]
3340                }]
3341            }),
3342            ClientLimits::default(),
3343        )
3344        .expect("test client");
3345        let table = client.schema.table("tasks").expect("tasks table");
3346        let sql = client.insert_row_sql(&base_table("tasks"), table);
3347
3348        client
3349            .conn
3350            .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
3351            .expect("insert first row");
3352        client
3353            .conn
3354            .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
3355            .expect("update same primary key");
3356        client
3357            .conn
3358            .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
3359            .expect("insert second row");
3360        assert!(client
3361            .conn
3362            .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
3363            .is_err());
3364
3365        let rows = client
3366            .conn
3367            .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
3368            .expect("prepare rows")
3369            .query_map([], |row| {
3370                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3371            })
3372            .expect("query rows")
3373            .collect::<Result<Vec<_>, _>>()
3374            .expect("collect rows");
3375        assert_eq!(
3376            rows,
3377            vec![
3378                ("t1".to_owned(), "updated".to_owned()),
3379                ("t2".to_owned(), "original".to_owned())
3380            ]
3381        );
3382    }
3383
3384    #[test]
3385    fn reopening_active_subscriptions_emits_a_catch_up_intent() {
3386        let path = std::env::temp_dir().join(format!(
3387            "syncular-startup-intent-{}.db",
3388            uuid::Uuid::new_v4()
3389        ));
3390        let schema = json!({
3391            "version": 1,
3392            "tables": [{
3393                "name": "tasks",
3394                "primaryKey": "id",
3395                "columns": [
3396                    { "name": "id", "type": "string", "nullable": false },
3397                    { "name": "project_id", "type": "string", "nullable": false }
3398                ],
3399                "scopes": [{ "pattern": "project:{project_id}" }]
3400            }]
3401        });
3402
3403        {
3404            let mut first = SyncClient::open_path_with_identity(
3405                None,
3406                &schema,
3407                ClientLimits::default(),
3408                path.to_str().expect("UTF-8 temp path"),
3409            )
3410            .expect("first open");
3411            first
3412                .set_window(
3413                    &WindowBase {
3414                        table: "tasks".to_owned(),
3415                        variable: "project_id".to_owned(),
3416                        fixed_scopes: Vec::new(),
3417                        params: None,
3418                    },
3419                    &["persisted".to_owned()],
3420                )
3421                .expect("persist window");
3422        }
3423
3424        let mut reopened = SyncClient::open_path_with_identity(
3425            None,
3426            &schema,
3427            ClientLimits::default(),
3428            path.to_str().expect("UTF-8 temp path"),
3429        )
3430        .expect("reopen");
3431        assert!(reopened.sync_needed());
3432        assert!(matches!(
3433            reopened.drain_sync_intents().as_slice(),
3434            [SyncIntent::Interactive]
3435        ));
3436        drop(reopened);
3437        std::fs::remove_file(path).expect("remove temp database");
3438    }
3439
3440    #[test]
3441    fn reopening_preserves_immutable_subscription_identity_and_progress() {
3442        let path = std::env::temp_dir().join(format!(
3443            "syncular-subscription-identity-{}.db",
3444            uuid::Uuid::new_v4()
3445        ));
3446        let schema = json!({
3447            "version": 1,
3448            "tables": [
3449                {
3450                    "name": "tasks",
3451                    "primaryKey": "id",
3452                    "columns": [
3453                        { "name": "id", "type": "string", "nullable": false },
3454                        { "name": "project_id", "type": "string", "nullable": false }
3455                    ],
3456                    "scopes": [{ "pattern": "project:{project_id}" }]
3457                },
3458                {
3459                    "name": "docs",
3460                    "primaryKey": "id",
3461                    "columns": [
3462                        { "name": "id", "type": "string", "nullable": false },
3463                        { "name": "org_id", "type": "string", "nullable": false },
3464                        { "name": "project_id", "type": "string", "nullable": false }
3465                    ],
3466                    "scopes": [
3467                        { "pattern": "org:{org_id}" },
3468                        { "pattern": "project:{projectId}", "column": "project_id" }
3469                    ]
3470                }
3471            ]
3472        });
3473
3474        {
3475            let mut first = SyncClient::open_path_with_identity(
3476                None,
3477                &schema,
3478                ClientLimits::default(),
3479                path.to_str().expect("UTF-8 temp path"),
3480            )
3481            .expect("first open");
3482            first
3483                .subscribe(
3484                    "stable-subscription".to_owned(),
3485                    "tasks".to_owned(),
3486                    vec![(
3487                        "project_id".to_owned(),
3488                        vec!["p2".to_owned(), "p1".to_owned()],
3489                    )],
3490                    Some(r#"{"view":"v1"}"#.to_owned()),
3491                )
3492                .expect("persist subscription");
3493            let persisted = {
3494                let subscription = first
3495                    .subs
3496                    .iter_mut()
3497                    .find(|subscription| subscription.id == "stable-subscription")
3498                    .expect("subscription");
3499                subscription.cursor = 41;
3500                subscription.bootstrap_state = Some("resume-token".to_owned());
3501                subscription.effective = Some(vec![(
3502                    "project_id".to_owned(),
3503                    vec!["p1".to_owned(), "p2".to_owned()],
3504                )]);
3505                subscription.synced_once = true;
3506                subscription.clone()
3507            };
3508            first.persist_sub(&persisted).unwrap();
3509        }
3510
3511        let mut reopened = SyncClient::open_path_with_identity(
3512            None,
3513            &schema,
3514            ClientLimits::default(),
3515            path.to_str().expect("UTF-8 temp path"),
3516        )
3517        .expect("reopen");
3518        let progress = reopened
3519            .subscription_state("stable-subscription")
3520            .expect("persisted state");
3521        assert_eq!(progress.cursor, 41);
3522        assert!(progress.has_resume_token);
3523
3524        reopened
3525            .subscribe(
3526                "stable-subscription".to_owned(),
3527                "tasks".to_owned(),
3528                vec![(
3529                    "project_id".to_owned(),
3530                    vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
3531                )],
3532                Some(r#"{"view":"v1"}"#.to_owned()),
3533            )
3534            .expect("canonical intent is idempotent");
3535        assert_eq!(
3536            reopened
3537                .subscription_state("stable-subscription")
3538                .expect("unchanged state")
3539                .cursor,
3540            progress.cursor
3541        );
3542
3543        for (table, scopes, params) in [
3544            (
3545                "tasks",
3546                vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3547                Some(r#"{"view":"v1"}"#.to_owned()),
3548            ),
3549            (
3550                "tasks",
3551                vec![(
3552                    "project_id".to_owned(),
3553                    vec!["p2".to_owned(), "p1".to_owned()],
3554                )],
3555                Some(r#"{"view":"v2"}"#.to_owned()),
3556            ),
3557            (
3558                "docs",
3559                vec![
3560                    ("org_id".to_owned(), vec!["o1".to_owned()]),
3561                    ("projectId".to_owned(), vec!["p1".to_owned()]),
3562                ],
3563                Some(r#"{"view":"v1"}"#.to_owned()),
3564            ),
3565        ] {
3566            let error = reopened
3567                .subscribe(
3568                    "stable-subscription".to_owned(),
3569                    table.to_owned(),
3570                    scopes,
3571                    params,
3572                )
3573                .expect_err("identity rebind must fail");
3574            assert!(error.starts_with("client.subscription_intent_mismatch:"));
3575            assert_eq!(
3576                reopened
3577                    .subscription_state("stable-subscription")
3578                    .expect("unchanged state")
3579                    .cursor,
3580                progress.cursor
3581            );
3582        }
3583
3584        drop(reopened);
3585        std::fs::remove_file(path).expect("remove temp database");
3586    }
3587
3588    #[test]
3589    fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
3590        let path = std::env::temp_dir().join(format!(
3591            "syncular-satisfied-schema-floor-{}.db",
3592            uuid::Uuid::new_v4()
3593        ));
3594        let schema = json!({
3595            "version": 23,
3596            "tables": [{
3597                "name": "tasks",
3598                "primaryKey": "id",
3599                "columns": [
3600                    { "name": "id", "type": "string", "nullable": false },
3601                    { "name": "project_id", "type": "string", "nullable": false }
3602                ],
3603                "scopes": [{ "pattern": "project:{project_id}" }]
3604            }]
3605        });
3606
3607        {
3608            let mut first = SyncClient::open_path_with_identity(
3609                None,
3610                &schema,
3611                ClientLimits::default(),
3612                path.to_str().expect("UTF-8 temp path"),
3613            )
3614            .expect("first open");
3615            first
3616                .subscribe(
3617                    "tasks".to_owned(),
3618                    "tasks".to_owned(),
3619                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3620                    None,
3621                )
3622                .expect("persist subscription");
3623            first.set_schema_floor(Some(SchemaFloor {
3624                required_schema_version: Some(22),
3625                latest_schema_version: Some(22),
3626            }));
3627        }
3628
3629        let mut reopened = SyncClient::open_path_with_identity(
3630            None,
3631            &schema,
3632            ClientLimits::default(),
3633            path.to_str().expect("UTF-8 temp path"),
3634        )
3635        .expect("reopen");
3636        assert!(reopened.schema_floor().is_none());
3637        assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
3638        assert!(reopened.sync_needed());
3639        assert!(matches!(
3640            reopened.drain_sync_intents().as_slice(),
3641            [SyncIntent::Interactive]
3642        ));
3643        drop(reopened);
3644        std::fs::remove_file(path).expect("remove temp database");
3645    }
3646
3647    #[test]
3648    fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
3649        let path = std::env::temp_dir().join(format!(
3650            "syncular-unsatisfied-schema-floor-{}.db",
3651            uuid::Uuid::new_v4()
3652        ));
3653        let schema = json!({
3654            "version": 1,
3655            "tables": [{
3656                "name": "tasks",
3657                "primaryKey": "id",
3658                "columns": [
3659                    { "name": "id", "type": "string", "nullable": false },
3660                    { "name": "project_id", "type": "string", "nullable": false }
3661                ],
3662                "scopes": [{ "pattern": "project:{project_id}" }]
3663            }]
3664        });
3665
3666        {
3667            let mut first = SyncClient::open_path_with_identity(
3668                None,
3669                &schema,
3670                ClientLimits::default(),
3671                path.to_str().expect("UTF-8 temp path"),
3672            )
3673            .expect("first open");
3674            first
3675                .subscribe(
3676                    "tasks".to_owned(),
3677                    "tasks".to_owned(),
3678                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3679                    None,
3680                )
3681                .expect("persist subscription");
3682            first.set_schema_floor(Some(SchemaFloor {
3683                required_schema_version: Some(2),
3684                latest_schema_version: Some(2),
3685            }));
3686        }
3687
3688        let reopened = SyncClient::open_path_with_identity(
3689            None,
3690            &schema,
3691            ClientLimits::default(),
3692            path.to_str().expect("UTF-8 temp path"),
3693        )
3694        .expect("reopen");
3695        assert_eq!(
3696            reopened.schema_floor(),
3697            Some(&SchemaFloor {
3698                required_schema_version: Some(2),
3699                latest_schema_version: Some(2),
3700            })
3701        );
3702        assert!(!reopened.sync_needed());
3703        drop(reopened);
3704        std::fs::remove_file(path).expect("remove temp database");
3705    }
3706
3707    #[test]
3708    fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
3709        let path = std::env::temp_dir().join(format!(
3710            "syncular-indexed-column-bump-{}.db",
3711            uuid::Uuid::new_v4()
3712        ));
3713        let old_schema = json!({
3714            "version": 1,
3715            "tables": [
3716                {
3717                    "name": "tasks",
3718                    "primaryKey": "id",
3719                    "columns": [
3720                        { "name": "id", "type": "string", "nullable": false },
3721                        { "name": "project_id", "type": "string", "nullable": false }
3722                    ],
3723                    "scopes": [{ "pattern": "project:{project_id}" }]
3724                },
3725                {
3726                    "name": "legacy",
3727                    "primaryKey": "id",
3728                    "columns": [
3729                        { "name": "id", "type": "string", "nullable": false },
3730                        { "name": "project_id", "type": "string", "nullable": false }
3731                    ],
3732                    "scopes": [{ "pattern": "project:{project_id}" }]
3733                }
3734            ]
3735        });
3736        {
3737            let mut first = SyncClient::open_path_with_identity(
3738                None,
3739                &old_schema,
3740                ClientLimits::default(),
3741                path.to_str().expect("UTF-8 temp path"),
3742            )
3743            .expect("open old schema");
3744            first
3745                .subscribe(
3746                    "legacy-sub".to_owned(),
3747                    "legacy".to_owned(),
3748                    vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3749                    None,
3750                )
3751                .expect("persist legacy subscription");
3752        }
3753
3754        let new_schema = json!({
3755            "version": 2,
3756            "tables": [{
3757                "name": "tasks",
3758                "primaryKey": "id",
3759                "columns": [
3760                    { "name": "id", "type": "string", "nullable": false },
3761                    { "name": "project_id", "type": "string", "nullable": false },
3762                    { "name": "facility_membership_id", "type": "string", "nullable": true }
3763                ],
3764                "scopes": [{ "pattern": "project:{project_id}" }],
3765                "indexes": [{
3766                    "name": "tasks_by_membership",
3767                    "columns": ["project_id", "facility_membership_id"],
3768                    "unique": false
3769                }]
3770            }]
3771        });
3772        let upgraded = SyncClient::open_path_with_identity(
3773            None,
3774            &new_schema,
3775            ClientLimits::default(),
3776            path.to_str().expect("UTF-8 temp path"),
3777        )
3778        .expect("open upgraded schema");
3779        let columns = upgraded
3780            .conn
3781            .prepare("PRAGMA table_info(tasks)")
3782            .expect("prepare columns")
3783            .query_map([], |row| row.get::<_, String>(1))
3784            .expect("query columns")
3785            .collect::<Result<Vec<_>, _>>()
3786            .expect("collect columns");
3787        assert!(columns.contains(&"facility_membership_id".to_owned()));
3788        let index_count: i64 = upgraded
3789            .conn
3790            .query_row(
3791                "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
3792                [],
3793                |row| row.get(0),
3794            )
3795            .expect("query index");
3796        assert_eq!(index_count, 1);
3797        assert!(upgraded.subscription_state("legacy-sub").is_none());
3798        drop(upgraded);
3799        std::fs::remove_file(path).expect("remove temp database");
3800    }
3801
3802    #[test]
3803    fn migrates_pre_envelope_outcome_journal_additively() {
3804        let path = std::env::temp_dir().join(format!(
3805            "syncular-outcome-migration-{}.db",
3806            uuid::Uuid::new_v4()
3807        ));
3808        let conn = Connection::open(&path).expect("open legacy database");
3809        conn.execute_batch(
3810            "CREATE TABLE _syncular_commit_outcomes (
3811               seq INTEGER PRIMARY KEY AUTOINCREMENT,
3812               client_commit_id TEXT NOT NULL UNIQUE,
3813               status TEXT NOT NULL,
3814               recorded_at_ms INTEGER NOT NULL,
3815               results_json TEXT NOT NULL,
3816               resolution TEXT NOT NULL DEFAULT 'active',
3817               resolved_at_ms INTEGER,
3818               replacement_client_commit_id TEXT);",
3819        )
3820        .expect("create legacy outcome journal");
3821        drop(conn);
3822        let schema = json!({
3823            "version": 1,
3824            "tables": [{
3825                "name": "tasks",
3826                "primaryKey": "id",
3827                "columns": [
3828                    { "name": "id", "type": "string", "nullable": false },
3829                    { "name": "project_id", "type": "string", "nullable": false }
3830                ],
3831                "scopes": [{ "pattern": "project:{project_id}" }]
3832            }]
3833        });
3834        let client = SyncClient::open_path(
3835            "migration-native".to_owned(),
3836            &schema,
3837            ClientLimits::default(),
3838            path.to_str().expect("UTF-8 temp path"),
3839        )
3840        .expect("migrate database");
3841        let has_operations = client
3842            .conn
3843            .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
3844            .expect("prepare table info")
3845            .query_map([], |row| row.get::<_, String>(1))
3846            .expect("query table info")
3847            .filter_map(Result::ok)
3848            .any(|column| column == "operations_json");
3849        assert!(has_operations);
3850        drop(client);
3851        std::fs::remove_file(path).expect("remove temp database");
3852    }
3853
3854    #[test]
3855    fn retained_rejection_survives_file_reopen_and_security_purge() {
3856        let path =
3857            std::env::temp_dir().join(format!("syncular-retained-{}.db", uuid::Uuid::new_v4()));
3858        let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id", "columns": [
3859            { "name": "id", "type": "string", "nullable": false },
3860            { "name": "project_id", "type": "string", "nullable": false },
3861            { "name": "title", "type": "string", "nullable": false }
3862        ], "scopes": [{ "pattern": "project:{project_id}" }] }] });
3863        {
3864            let mut first = SyncClient::open_path(
3865                "retained-native".to_owned(),
3866                &schema,
3867                ClientLimits::default(),
3868                path.to_str().unwrap(),
3869            )
3870            .unwrap();
3871            first
3872                .write_base_row(
3873                    "tasks",
3874                    &vec![
3875                        Some(ColumnValue::String("t1".into())),
3876                        Some(ColumnValue::String("p1".into())),
3877                        Some(ColumnValue::String("server".into())),
3878                    ],
3879                    1,
3880                )
3881                .unwrap();
3882            first.rebuild_overlay();
3883            let operation = OutboxOp {
3884                table: "tasks".into(),
3885                row_id: "t1".into(),
3886                upsert: true,
3887                base_version: Some(1),
3888                values: Some(Map::from_iter([
3889                    ("id".into(), json!("t1")),
3890                    ("title".into(), json!("mine")),
3891                ])),
3892            };
3893            first
3894                .retain_failed_commit("retained", std::slice::from_ref(&operation))
3895                .unwrap();
3896            first
3897                .persist_commit_outcome(
3898                    "retained",
3899                    CommitOutcomeStatus::Rejected,
3900                    &[],
3901                    Some(&[operation]),
3902                )
3903                .unwrap();
3904            first.rebuild_overlay();
3905        }
3906        {
3907            let mut reopened = SyncClient::open_path(
3908                "retained-native".to_owned(),
3909                &schema,
3910                ClientLimits::default(),
3911                path.to_str().unwrap(),
3912            )
3913            .unwrap();
3914            let row = reopened
3915                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
3916                .unwrap();
3917            assert_eq!(row.0[2], Some(ColumnValue::String("mine".into())));
3918            assert_eq!(
3919                reopened
3920                    .commit_outcome("retained")
3921                    .unwrap()
3922                    .unwrap()
3923                    .retained_rows
3924                    .unwrap()[0]
3925                    .server_row
3926                    .as_ref()
3927                    .unwrap()["title"],
3928                json!("server")
3929            );
3930            reopened
3931                .conn
3932                .execute("DELETE FROM _syncular_base_tasks WHERE id = 't1'", [])
3933                .unwrap();
3934            reopened.rebuild_overlay();
3935            assert!(reopened
3936                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
3937                .is_none());
3938            let retained = reopened.commit_outcome("retained").unwrap().unwrap();
3939            assert!(retained.retained_rows.is_some());
3940            assert_eq!(
3941                retained.operations.unwrap()[0].values.as_ref().unwrap()["title"],
3942                json!("mine")
3943            );
3944            reopened
3945                .purge_local_data(&LocalDataPurgeInput {
3946                    purge_id: "protected-purge".into(),
3947                    targets: vec![LocalDataPurgeTarget {
3948                        table: "tasks".into(),
3949                        selectors: BTreeMap::from_iter([("project_id".into(), vec!["p1".into()])]),
3950                    }],
3951                })
3952                .unwrap();
3953            assert!(reopened
3954                .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
3955                .is_none());
3956            assert!(reopened
3957                .commit_outcome("retained")
3958                .unwrap()
3959                .unwrap()
3960                .retained_rows
3961                .is_none());
3962        }
3963        std::fs::remove_file(path).unwrap();
3964    }
3965
3966    #[test]
3967    fn durable_conflict_outcome_and_resolution_survive_reopen() {
3968        let path = std::env::temp_dir().join(format!(
3969            "syncular-durable-outcome-{}.db",
3970            uuid::Uuid::new_v4()
3971        ));
3972        let schema = json!({
3973            "version": 1,
3974            "tables": [{
3975                "name": "tasks",
3976                "primaryKey": "id",
3977                "columns": [
3978                    { "name": "id", "type": "string", "nullable": false },
3979                    { "name": "project_id", "type": "string", "nullable": false }
3980                ],
3981                "scopes": [{ "pattern": "project:{project_id}" }]
3982            }]
3983        });
3984        let conflict = ConflictRecord {
3985            client_commit_id: "losing-commit".to_owned(),
3986            op_index: 0,
3987            table: "tasks".to_owned(),
3988            row_id: "t1".to_owned(),
3989            code: "sync.version_conflict".to_owned(),
3990            message: "stale base version".to_owned(),
3991            server_version: 2,
3992            server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
3993            conflict_columns: vec!["project_id".to_owned()],
3994            operation: Some(CommitOperation {
3995                table: "tasks".to_owned(),
3996                row_id: "t1".to_owned(),
3997                op: "upsert".to_owned(),
3998                base_version: Some(1),
3999                values: None,
4000            }),
4001        };
4002        let failed_operations = vec![
4003            OutboxOp {
4004                upsert: true,
4005                table: "tasks".to_owned(),
4006                row_id: "t1".to_owned(),
4007                base_version: Some(1),
4008                values: None,
4009            },
4010            OutboxOp {
4011                upsert: true,
4012                table: "tasks".to_owned(),
4013                row_id: "status-event-1".to_owned(),
4014                base_version: Some(0),
4015                values: None,
4016            },
4017        ];
4018
4019        {
4020            let mut first = SyncClient::open_path(
4021                "durable-native".to_owned(),
4022                &schema,
4023                ClientLimits::default(),
4024                path.to_str().expect("UTF-8 temp path"),
4025            )
4026            .expect("first open");
4027            first
4028                .begin_observation("test_outcome")
4029                .expect("begin outcome");
4030            first
4031                .persist_commit_outcome(
4032                    "losing-commit",
4033                    CommitOutcomeStatus::Conflict,
4034                    &[CommitOperationOutcome::Conflict {
4035                        conflict: conflict.clone(),
4036                    }],
4037                    Some(&failed_operations),
4038                )
4039                .expect("persist outcome");
4040            first.conflicts.push(conflict);
4041            first
4042                .finish_observation(
4043                    "test_outcome",
4044                    ChangeAccumulator {
4045                        conflicts: true,
4046                        outcomes: true,
4047                        ..ChangeAccumulator::default()
4048                    },
4049                )
4050                .expect("commit outcome");
4051        }
4052
4053        {
4054            let mut reopened = SyncClient::open_path(
4055                "durable-native".to_owned(),
4056                &schema,
4057                ClientLimits::default(),
4058                path.to_str().expect("UTF-8 temp path"),
4059            )
4060            .expect("reopen");
4061            assert_eq!(reopened.conflicts().len(), 1);
4062            assert_eq!(
4063                reopened.conflicts()[0].conflict_columns,
4064                vec!["project_id".to_owned()],
4065                "conflictColumns survives the journal round trip"
4066            );
4067            let outcome = reopened
4068                .commit_outcome("losing-commit")
4069                .expect("read outcome")
4070                .expect("outcome");
4071            assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
4072            let operations = outcome.operations.expect("aggregate envelope");
4073            assert_eq!(operations.len(), 2);
4074            assert_eq!(operations[1].row_id, "status-event-1");
4075            let resolved = reopened
4076                .resolve_commit_outcome(ResolveCommitOutcomeInput {
4077                    client_commit_id: "losing-commit".to_owned(),
4078                    resolution: CommitOutcomeResolution::ResolvedKeepServer,
4079                    replacement_client_commit_id: None,
4080                })
4081                .expect("resolve");
4082            assert_eq!(
4083                resolved.resolution,
4084                CommitOutcomeResolution::ResolvedKeepServer
4085            );
4086            assert!(reopened.conflicts().is_empty());
4087        }
4088
4089        let reopened = SyncClient::open_path(
4090            "durable-native".to_owned(),
4091            &schema,
4092            ClientLimits::default(),
4093            path.to_str().expect("UTF-8 temp path"),
4094        )
4095        .expect("second reopen");
4096        assert!(reopened.conflicts().is_empty());
4097        assert_eq!(
4098            reopened
4099                .commit_outcome("losing-commit")
4100                .expect("read outcome")
4101                .expect("outcome")
4102                .resolution,
4103            CommitOutcomeResolution::ResolvedKeepServer
4104        );
4105        drop(reopened);
4106        std::fs::remove_file(path).expect("remove temp database");
4107    }
4108
4109    #[test]
4110    fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
4111        let path =
4112            std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
4113        let schema = json!({
4114            "version": 1,
4115            "tables": [{
4116                "name": "tasks",
4117                "primaryKey": "id",
4118                "columns": [
4119                    { "name": "id", "type": "string", "nullable": false },
4120                    { "name": "project_id", "type": "string", "nullable": false }
4121                ],
4122                "scopes": [{ "pattern": "project:{project_id}" }]
4123            }]
4124        });
4125        let mut client = SyncClient::open_path_with_identity(
4126            Some("sidecar-client".to_owned()),
4127            &schema,
4128            ClientLimits::default(),
4129            path.to_str().expect("UTF-8 temp path"),
4130        )
4131        .expect("open owner");
4132        let base = WindowBase {
4133            table: "tasks".to_owned(),
4134            variable: "project_id".to_owned(),
4135            fixed_scopes: Vec::new(),
4136            params: None,
4137        };
4138        client
4139            .set_window(&base, &["one".to_owned()])
4140            .expect("set window");
4141        client
4142            .mutate(vec![Mutation::Upsert {
4143                table: "tasks".to_owned(),
4144                values: Map::from_iter([
4145                    ("id".to_owned(), Value::from("t1")),
4146                    ("project_id".to_owned(), Value::from("one")),
4147                ]),
4148                base_version: None,
4149            }])
4150            .expect("local mutate");
4151
4152        let coverage = [WindowCoverage {
4153            base,
4154            units: vec!["one".to_owned(), "missing".to_owned()],
4155        }];
4156        let owner = client
4157            .query_snapshot(
4158                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
4159                &[],
4160                &coverage,
4161                None,
4162            )
4163            .expect("owner snapshot");
4164        let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
4165        let sidecar = reader
4166            .query_snapshot(
4167                "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
4168                &[],
4169                &coverage,
4170            )
4171            .expect("sidecar snapshot");
4172
4173        assert_eq!(sidecar.revision, owner.revision);
4174        assert_eq!(sidecar.rows, owner.rows);
4175        assert_eq!(
4176            serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
4177            serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
4178        );
4179        assert_eq!(sidecar.revision, "2");
4180        assert_eq!(sidecar.rows[0]["id"], "t1");
4181        assert_eq!(sidecar.rows[0]["server_version"], -1);
4182        assert!(!sidecar.coverage.complete);
4183        assert_eq!(sidecar.coverage.pending.len(), 1);
4184        assert_eq!(sidecar.coverage.missing.len(), 1);
4185
4186        drop(reader);
4187        drop(client);
4188        std::fs::remove_file(path).expect("remove temp database");
4189    }
4190
4191    #[test]
4192    fn owned_query_failures_classify_order_bound_and_clear() {
4193        let io = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
4194            rusqlite::ffi::Error::new(266),
4195            Some("private SQLite prose".to_owned()),
4196        ));
4197        assert_eq!(io.code, Some("client.storage_io"));
4198        assert_eq!(io.sqlite_code, Some(266));
4199        assert_eq!(io.message, "local SQLite storage I/O failed");
4200        let corrupt = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
4201            rusqlite::ffi::Error::new(26),
4202            None,
4203        ));
4204        assert_eq!(corrupt.code, Some("client.storage_corrupt"));
4205
4206        let mut client = client();
4207        let generic = QueryReadFailure::from("private query prose".to_owned());
4208        client.record_query_read(
4209            QueryOwner {
4210                id: "first",
4211                tables: &["tasks", "tasks"],
4212            },
4213            Some(&generic),
4214        );
4215        client.record_query_read(
4216            QueryOwner {
4217                id: "second",
4218                tables: &["tasks"],
4219            },
4220            Some(&generic),
4221        );
4222        client.record_query_read(
4223            QueryOwner {
4224                id: "first",
4225                tables: &["docs"],
4226            },
4227            Some(&io),
4228        );
4229        let failures = &client
4230            .diagnostics_snapshot(&Default::default())
4231            .expect("diagnostics")
4232            .query_failures;
4233        assert_eq!(
4234            failures
4235                .iter()
4236                .map(|entry| entry.id.as_str())
4237                .collect::<Vec<_>>(),
4238            vec!["second", "first"]
4239        );
4240        assert_eq!(failures[1].tables, vec!["docs"]);
4241        assert_eq!(failures[1].code, "client.storage_io");
4242
4243        for index in 0..=MAX_DIAGNOSTIC_QUERY_FAILURES {
4244            client.record_query_read(
4245                QueryOwner {
4246                    id: &format!("query-{index}"),
4247                    tables: &["tasks"],
4248                },
4249                Some(&generic),
4250            );
4251        }
4252        let failures = &client
4253            .diagnostics_snapshot(&Default::default())
4254            .expect("bounded diagnostics")
4255            .query_failures;
4256        assert_eq!(failures.len(), MAX_DIAGNOSTIC_QUERY_FAILURES);
4257        assert_eq!(failures.last().expect("last failure").id, "query-256");
4258
4259        client
4260            .query_snapshot(
4261                "SELECT id FROM tasks",
4262                &[],
4263                &[],
4264                Some(QueryOwner {
4265                    id: "query-256",
4266                    tables: &["tasks"],
4267                }),
4268            )
4269            .expect("successful owned read");
4270        assert!(client
4271            .diagnostics_snapshot(&Default::default())
4272            .expect("cleared diagnostics")
4273            .query_failures
4274            .iter()
4275            .all(|entry| entry.id != "query-256"));
4276    }
4277
4278    #[test]
4279    fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
4280        let mut client = client();
4281        let image = Connection::open_in_memory().unwrap();
4282        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
4283            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
4284            INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
4285            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4286            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
4287        let table = client.schema.table("tasks").unwrap().clone();
4288        let scopes = vec![("project_id".into(), vec!["p1".into()])];
4289        assert!(client
4290            .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
4291            .is_err());
4292        assert!(client.conn.is_autocommit());
4293        assert_eq!(
4294            client
4295                .query("SELECT count(*) AS n FROM tasks", &[])
4296                .unwrap()[0]["n"],
4297            1024
4298        );
4299        assert_eq!(client.drain_change_batches().len(), 1);
4300        image
4301            .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
4302            .unwrap();
4303        assert!(matches!(
4304            client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
4305            Ok(2050)
4306        ));
4307        assert_eq!(
4308            client
4309                .query("SELECT count(*) AS n FROM tasks", &[])
4310                .unwrap()[0]["n"],
4311            2050
4312        );
4313        assert_eq!(client.drain_change_batches().len(), 3);
4314    }
4315
4316    #[test]
4317    fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
4318        let mut client = client();
4319        let base = WindowBase {
4320            table: "tasks".into(),
4321            variable: "project_id".into(),
4322            fixed_scopes: Vec::new(),
4323            params: None,
4324        };
4325        client
4326            .set_window(&base, &["p1".into(), "p2".into()])
4327            .unwrap();
4328        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
4329            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
4330            INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
4331            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
4332            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
4333        client.drain_change_batches();
4334        assert!(client.set_window(&base, &["p2".into()]).is_err());
4335        assert!(client.conn.is_autocommit());
4336        assert_eq!(
4337            client
4338                .query("SELECT count(*) AS n FROM tasks", &[])
4339                .unwrap()[0]["n"],
4340            1027
4341        );
4342        assert_eq!(client.window_state(&base).units, vec!["p2"]);
4343        assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
4344        assert_eq!(client.drain_change_batches().len(), 1);
4345        client
4346            .conn
4347            .execute_batch("DROP TRIGGER interrupt_evict")
4348            .unwrap();
4349        client.drain_pending_evictions().unwrap();
4350        assert!(client.load_pending_evictions().unwrap().is_empty());
4351        assert_eq!(
4352            client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
4353            "held"
4354        );
4355        assert_eq!(client.drain_change_batches().len(), 2);
4356    }
4357
4358    #[test]
4359    fn eviction_marker_failures_roll_back_window_changes() {
4360        let mut client = client();
4361        let base = WindowBase {
4362            table: "tasks".into(),
4363            variable: "project_id".into(),
4364            fixed_scopes: Vec::new(),
4365            params: None,
4366        };
4367        client.set_window(&base, &["p1".into()]).unwrap();
4368        client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
4369            INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
4370            INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
4371            CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
4372        let revision = client.local_revision();
4373        assert!(client.set_window(&base, &[]).is_err());
4374        assert!(client.conn.is_autocommit());
4375        assert_eq!(client.local_revision(), revision);
4376        assert_eq!(
4377            client
4378                .query("SELECT count(*) AS n FROM tasks", &[])
4379                .unwrap()[0]["n"],
4380            2050
4381        );
4382        assert_eq!(client.window_state(&base).units, vec!["p1"]);
4383        client.conn.execute_batch("DROP TRIGGER fail_marker;
4384            CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
4385        assert!(client.set_window(&base, &[]).is_err());
4386        client.conn.execute_batch("DROP TRIGGER interrupt_evict;
4387            CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
4388        assert!(client.drain_pending_evictions().is_err());
4389        assert!(client.conn.is_autocommit());
4390        assert_eq!(
4391            client
4392                .query("SELECT count(*) AS n FROM tasks", &[])
4393                .unwrap()[0]["n"],
4394            2
4395        );
4396        assert!(client.set_window(&base, &["p1".into()]).is_err());
4397        assert!(client.conn.is_autocommit());
4398        assert!(client.window_state(&base).units.is_empty());
4399        assert!(client.subs.is_empty());
4400        assert_eq!(
4401            client
4402                .query(
4403                    "SELECT count(*) AS n FROM _syncular_window_pending_evict",
4404                    &[]
4405                )
4406                .unwrap()[0]["n"],
4407            1
4408        );
4409        client
4410            .conn
4411            .execute_batch("DROP TRIGGER fail_marker")
4412            .unwrap();
4413        client.set_window(&base, &["p1".into()]).unwrap();
4414        assert_eq!(client.window_state(&base).units, vec!["p1"]);
4415    }
4416
4417    #[test]
4418    fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
4419        let mut client = client();
4420        client
4421            .conn
4422            .execute(
4423                "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
4424                [],
4425            )
4426            .unwrap();
4427        assert_eq!(
4428            client.drain_pending_evictions().unwrap_err(),
4429            "sync.local_corrupt: invalid pending eviction scopes"
4430        );
4431        assert!(client.conn.is_autocommit());
4432        assert!(client.enqueue_startup_sync_if_needed().is_err());
4433    }
4434
4435    #[test]
4436    fn image_chunks_reject_null_primary_keys() {
4437        let mut client = client();
4438        let image = Connection::open_in_memory().unwrap();
4439        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4440            WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
4441            INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
4442            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4443            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
4444        let table = client.schema.table("tasks").unwrap().clone();
4445        assert!(client
4446            .apply_sqlite_image(
4447                &image,
4448                &table,
4449                true,
4450                &[("project_id".into(), vec!["p1".into()])],
4451                1025,
4452                7,
4453                "digest"
4454            )
4455            .is_err());
4456        assert!(client.conn.is_autocommit());
4457        assert_eq!(
4458            client
4459                .query("SELECT count(*) AS n FROM tasks", &[])
4460                .unwrap()[0]["n"],
4461            0
4462        );
4463    }
4464
4465    #[test]
4466    fn import_chunks_with_unique_constraints_match_full_pending_replay() {
4467        for images in [false, true] {
4468            let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
4469                "name":name,"primaryKey":"id","columns":[
4470                    {"name":"id","type":"string","nullable":false},
4471                    {"name":"project_id","type":"string","nullable":false},
4472                    {"name":"title","type":"string","nullable":false}],
4473                "scopes":[{"pattern":"project:{project_id}"}],
4474                "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
4475                "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
4476            })))});
4477            let mut actual =
4478                SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
4479            let mut reference =
4480                SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
4481            let scopes = vec![("project_id".into(), vec!["p1".into()])];
4482            for client in [&mut actual, &mut reference] {
4483                client
4484                    .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
4485                    .unwrap();
4486                client.subs[0].effective = Some(scopes.clone());
4487                for (table, id, title) in [
4488                    ("tasks", "a", "original"),
4489                    ("tasks", "b", "occupied"),
4490                    ("tasks", "c", "deleted"),
4491                    ("notes", "n", "untouched"),
4492                ] {
4493                    client
4494                        .write_base_row(
4495                            table,
4496                            &vec![
4497                                Some(ColumnValue::String(id.into())),
4498                                Some(ColumnValue::String("p1".into())),
4499                                Some(ColumnValue::String(title.into())),
4500                            ],
4501                            1,
4502                        )
4503                        .unwrap();
4504                }
4505                client.rebuild_overlay_if_dirty();
4506                client
4507                    .mutate(vec![
4508                        Mutation::Upsert {
4509                            table: "tasks".into(),
4510                            values: Map::from_iter([
4511                                ("id".into(), Value::from("a")),
4512                                ("project_id".into(), Value::from("p1")),
4513                                ("title".into(), Value::from("available")),
4514                            ]),
4515                            base_version: None,
4516                        },
4517                        Mutation::Delete {
4518                            table: "tasks".into(),
4519                            row_id: "c".into(),
4520                            base_version: None,
4521                        },
4522                        Mutation::Upsert {
4523                            table: "tasks".into(),
4524                            values: Map::from_iter([
4525                                ("id".into(), Value::from("local")),
4526                                ("project_id".into(), Value::from("p1")),
4527                                ("title".into(), Value::from("local value")),
4528                            ]),
4529                            base_version: None,
4530                        },
4531                        Mutation::Upsert {
4532                            table: "notes".into(),
4533                            values: Map::from_iter([
4534                                ("id".into(), Value::from("n")),
4535                                ("project_id".into(), Value::from("p1")),
4536                                ("title".into(), Value::from("pending note")),
4537                            ]),
4538                            base_version: None,
4539                        },
4540                    ])
4541                    .unwrap();
4542            }
4543            let pending = actual.pending_commit_ids();
4544            actual.overlay_rebuild_count.set(0);
4545            for (id, title) in [
4546                ("b", "available"),
4547                ("b", "occupied"),
4548                ("a", "server original"),
4549                ("d", "local value"),
4550                ("d", "released"),
4551                ("c", "redelivered"),
4552            ] {
4553                let table = actual.schema.table("tasks").unwrap().clone();
4554                let values = vec![
4555                    Some(ColumnValue::String(id.into())),
4556                    Some(ColumnValue::String("p1".into())),
4557                    Some(ColumnValue::String(title.into())),
4558                ];
4559                if images {
4560                    let image = Connection::open_in_memory().unwrap();
4561                    image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4562                        CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4563                        INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
4564                    image
4565                        .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
4566                        .unwrap();
4567                    assert!(matches!(
4568                        actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
4569                        Ok(1)
4570                    ));
4571                } else {
4572                    let segment = RowsSegment {
4573                        table: "tasks".into(),
4574                        schema_version: 1,
4575                        columns: table.wire_columns.clone(),
4576                        blocks: vec![vec![ssp2::segment::SegmentRow {
4577                            server_version: 2,
4578                            values: values.clone(),
4579                        }]],
4580                    };
4581                    assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
4582                }
4583                reference.write_base_row("tasks", &values, 2).unwrap();
4584                reference.rebuild_overlay();
4585                for table in ["tasks", "notes"] {
4586                    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")] {
4587                        assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
4588                    }
4589                }
4590                assert_eq!(actual.pending_commit_ids(), pending);
4591            }
4592            assert_eq!(
4593                actual.overlay_rebuild_count.get(),
4594                0,
4595                "each chunk must leave unrelated tables alone"
4596            );
4597            let before = actual
4598                .query("SELECT * FROM tasks ORDER BY id", &[])
4599                .unwrap();
4600            let fts_before = actual
4601                .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
4602                .unwrap();
4603            let revision = actual.local_revision();
4604            actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
4605            let table = actual.schema.table("tasks").unwrap().clone();
4606            let segment = RowsSegment {
4607                table: "tasks".into(),
4608                schema_version: 1,
4609                columns: table.wire_columns.clone(),
4610                blocks: vec![vec![ssp2::segment::SegmentRow {
4611                    server_version: 3,
4612                    values: vec![
4613                        Some(ColumnValue::String("b".into())),
4614                        Some(ColumnValue::String("p1".into())),
4615                        Some(ColumnValue::String("fresh".into())),
4616                    ],
4617                }]],
4618            };
4619            assert!(actual.apply_segment(0, &segment, false).is_err());
4620            assert!(actual.conn.is_autocommit());
4621            assert_eq!(
4622                actual
4623                    .query("SELECT * FROM tasks ORDER BY id", &[])
4624                    .unwrap(),
4625                before
4626            );
4627            assert_eq!(
4628                actual
4629                    .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
4630                    .unwrap(),
4631                fts_before
4632            );
4633            assert_eq!(actual.local_revision(), revision);
4634            assert_eq!(actual.pending_commit_ids(), pending);
4635        }
4636    }
4637
4638    #[test]
4639    fn import_reconciliation_preserves_typed_pending_primary_keys() {
4640        for (kind, a, b) in [
4641            ("string", json!("a"), json!("b")),
4642            ("integer", json!(1), json!(2)),
4643            ("boolean", json!(true), json!(false)),
4644            ("json", json!("1"), json!("2")),
4645        ] {
4646            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}]}]});
4647            for images in [false, true] {
4648                let mut client =
4649                    SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
4650                let table = client.schema.table("tasks").unwrap().clone();
4651                client
4652                    .subscribe("tasks".into(), "tasks".into(), vec![], None)
4653                    .unwrap();
4654                for (id, title) in [(&a, "original"), (&b, "occupied")] {
4655                    client
4656                        .write_base_row(
4657                            "tasks",
4658                            &vec![
4659                                json_to_column_value(&table.columns[0], Some(id)).unwrap(),
4660                                Some(ColumnValue::String(title.into())),
4661                            ],
4662                            1,
4663                        )
4664                        .unwrap();
4665                }
4666                client.rebuild_overlay_if_dirty();
4667                client
4668                    .mutate(vec![Mutation::Upsert {
4669                        table: "tasks".into(),
4670                        values: Map::from_iter([
4671                            ("id".into(), a.clone()),
4672                            ("title".into(), json!("available")),
4673                        ]),
4674                        base_version: None,
4675                    }])
4676                    .unwrap();
4677                client.overlay_rebuild_count.set(0);
4678                for (title, expected) in [("available", "original"), ("occupied", "available")] {
4679                    let values = vec![
4680                        json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
4681                        Some(ColumnValue::String(title.into())),
4682                    ];
4683                    if images {
4684                        let image = Connection::open_in_memory().unwrap();
4685                        image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4686                            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
4687                            INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
4688                        image
4689                            .execute(
4690                                "INSERT INTO tasks VALUES (?1,?2,2)",
4691                                rusqlite::params![RowParam::Cell(&values[0]), title],
4692                            )
4693                            .unwrap();
4694                        assert!(
4695                            matches!(
4696                                client.apply_sqlite_image(
4697                                    &image,
4698                                    &table,
4699                                    false,
4700                                    &[],
4701                                    1,
4702                                    7,
4703                                    "digest"
4704                                ),
4705                                Ok(1)
4706                            ),
4707                            "{kind}, images={images}"
4708                        );
4709                    } else {
4710                        let segment = RowsSegment {
4711                            table: "tasks".into(),
4712                            schema_version: 1,
4713                            columns: table.wire_columns.clone(),
4714                            blocks: vec![vec![ssp2::segment::SegmentRow {
4715                                server_version: 2,
4716                                values,
4717                            }]],
4718                        };
4719                        assert!(
4720                            matches!(client.apply_segment(0, &segment, false), Ok(1)),
4721                            "{kind}"
4722                        );
4723                    }
4724                    let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
4725                    let actual: String = client
4726                        .conn
4727                        .query_row(
4728                            "SELECT title FROM tasks WHERE id=?1",
4729                            [RowParam::Cell(&key)],
4730                            |row| row.get(0),
4731                        )
4732                        .unwrap();
4733                    assert_eq!(actual, expected, "{kind}, images={images}");
4734                }
4735                assert_eq!(client.overlay_rebuild_count.get(), 0);
4736            }
4737        }
4738    }
4739
4740    fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
4741        let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
4742            "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
4743            "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
4744            "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
4745        })))});
4746        let mut client =
4747            SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
4748        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();
4749        client.rebuild_overlay();
4750        client
4751            .mutate(vec![Mutation::Upsert {
4752                table: "tasks".into(),
4753                values: Map::from_iter([
4754                    ("id".into(), json!("offline")),
4755                    ("project_id".into(), json!("p1")),
4756                    ("title".into(), json!("pending edit")),
4757                ]),
4758                base_version: None,
4759            }])
4760            .unwrap();
4761        let image = Connection::open_in_memory().unwrap();
4762        image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
4763            CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
4764        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();
4765        image
4766            .execute(
4767                "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
4768                [rows],
4769            )
4770            .unwrap();
4771        client.overlay_rebuild_count.set(0);
4772        let table = client.schema.table("tasks").unwrap().clone();
4773        (client, image, table)
4774    }
4775
4776    #[test]
4777    fn sqlite_full_image_import_retains_the_first_code_and_recovers() {
4778        let (mut client, image, table) = unique_import_fixture(1);
4779        image
4780            .execute("UPDATE tasks SET title = ?1", ["full ".repeat(32768)])
4781            .unwrap();
4782        client
4783            .conn
4784            .execute_batch("PRAGMA max_page_count = 1")
4785            .unwrap();
4786        let effective = [("project_id".into(), vec!["p1".into()])];
4787        assert!(client
4788            .apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest")
4789            .is_err());
4790        let failure = client.storage_failure.borrow().clone().unwrap();
4791        assert_eq!(failure.code, Some("client.storage_full"));
4792        assert_eq!(failure.sqlite_code, Some(13));
4793        assert!(!failure.message.contains("rollback"));
4794        assert!(client.conn.is_autocommit());
4795        client
4796            .conn
4797            .execute_batch("PRAGMA max_page_count = 1073741823")
4798            .unwrap();
4799        assert!(matches!(
4800            client.apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest"),
4801            Ok(1)
4802        ));
4803        assert!(client.conn.is_autocommit());
4804    }
4805
4806    #[test]
4807    fn large_unique_import_copies_no_unrelated_tables() {
4808        let (mut client, image, table) = unique_import_fixture(10240);
4809        let pending = client.pending_commit_ids();
4810        client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
4811            CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
4812        assert!(matches!(
4813            client.apply_sqlite_image(
4814                &image,
4815                &table,
4816                true,
4817                &[("project_id".into(), vec!["p1".into()])],
4818                10240,
4819                7,
4820                "digest"
4821            ),
4822            Ok(10240)
4823        ));
4824        assert_eq!(client.overlay_rebuild_count.get(), 0);
4825        assert_eq!(client.pending_commit_ids(), pending);
4826        assert_eq!(
4827            client
4828                .query("SELECT count(*) AS n FROM tasks", &[])
4829                .unwrap()[0]["n"],
4830            10241
4831        );
4832        assert_eq!(
4833            client
4834                .query("SELECT count(*) AS n FROM tasks_fts", &[])
4835                .unwrap()[0]["n"],
4836            10241
4837        );
4838        assert_eq!(
4839            client
4840                .query("SELECT count(*) AS n FROM notes_fts", &[])
4841                .unwrap()[0]["n"],
4842            8192
4843        );
4844    }
4845
4846    #[test]
4847    #[ignore = "manual scaling benchmark; see bench/README.md"]
4848    fn benchmark_unique_image_import() {
4849        let mut samples = Vec::new();
4850        for rows in [10240, 51200, 102400] {
4851            for trial in 0..4 {
4852                let (mut client, image, table) = unique_import_fixture(rows);
4853                let start = std::time::Instant::now();
4854                assert!(
4855                    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)
4856                );
4857                let elapsed = start.elapsed().as_secs_f64() * 1000.0;
4858                assert_eq!(
4859                    client
4860                        .query("SELECT count(*) AS n FROM tasks", &[])
4861                        .unwrap()[0]["n"],
4862                    rows + 1
4863                );
4864                assert_eq!(
4865                    client
4866                        .query("SELECT count(*) AS n FROM tasks_fts", &[])
4867                        .unwrap()[0]["n"],
4868                    rows + 1
4869                );
4870                assert_eq!(
4871                    client
4872                        .query("SELECT count(*) AS n FROM notes_fts", &[])
4873                        .unwrap()[0]["n"],
4874                    8192
4875                );
4876                assert_eq!(client.pending_commit_ids().len(), 1);
4877                if trial > 0 {
4878                    samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
4879                }
4880            }
4881        }
4882        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});
4883        let output = serde_json::to_string_pretty(&artifact).unwrap();
4884        if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
4885            use std::io::Write;
4886            let mut file = std::fs::OpenOptions::new()
4887                .write(true)
4888                .create_new(true)
4889                .open(path)
4890                .unwrap();
4891            file.write_all(output.as_bytes()).unwrap();
4892        } else {
4893            println!("{output}");
4894        }
4895    }
4896
4897    #[test]
4898    fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
4899        let schema = json!({
4900            "version": 1,
4901            "tables": [{
4902                "name": "catalogue_codes",
4903                "primaryKey": "id",
4904                "columns": [
4905                    { "name": "id", "type": "string", "nullable": false },
4906                    { "name": "release_id", "type": "string", "nullable": false },
4907                    { "name": "code", "type": "string", "nullable": false },
4908                    { "name": "title", "type": "string", "nullable": false }
4909                ],
4910                "scopes": [{ "pattern": "release:{release_id}" }],
4911                "ftsIndexes": [{
4912                    "name": "catalogue_codes_fts",
4913                    "columns": ["code", "title"],
4914                    "tokenize": "unicode61 remove_diacritics 2"
4915                }]
4916            }]
4917        });
4918        let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
4919            .expect("FTS5 client");
4920        let insert_trigger: String = client
4921            .conn
4922            .query_row(
4923                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
4924                [],
4925                |row| row.get(0),
4926            )
4927            .expect("insert trigger");
4928        let replace_guard: String = client
4929            .conn
4930            .query_row(
4931                "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
4932                [],
4933                |row| row.get(0),
4934            )
4935            .expect("replace guard");
4936        assert!(!insert_trigger.contains("DELETE FROM"));
4937        assert!(replace_guard.contains("BEFORE INSERT"));
4938        assert!(replace_guard.contains("WHEN EXISTS"));
4939        let search = |client: &SyncClient, query: &str| {
4940            client
4941                .query(
4942                    "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",
4943                    &[Value::from(query)],
4944                )
4945                .expect("FTS query")
4946        };
4947
4948        client
4949            .mutate(vec![Mutation::Upsert {
4950                table: "catalogue_codes".to_owned(),
4951                values: Map::from_iter([
4952                    ("id".to_owned(), Value::from("c1")),
4953                    ("release_id".to_owned(), Value::from("r1")),
4954                    ("code".to_owned(), Value::from("A01")),
4955                    ("title".to_owned(), Value::from("Cholera")),
4956                ]),
4957                base_version: None,
4958            }])
4959            .expect("insert code");
4960        assert_eq!(search(&client, "cholera").len(), 1);
4961        let prior = client
4962            .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
4963            .unwrap();
4964        client
4965            .conn
4966            .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
4967            .unwrap();
4968        client.create_synced_tables().unwrap();
4969        assert_eq!(
4970            client
4971                .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
4972                .unwrap(),
4973            prior
4974        );
4975        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();
4976        let rendered = serde_json::to_string(&plan).unwrap();
4977        assert!(rendered.contains("COVERING INDEX"));
4978        assert!(rendered.contains("INDEX 0:="));
4979        client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
4980        assert_eq!(search(&client, "cholera").len(), 1);
4981
4982        client
4983            .mutate(vec![Mutation::Upsert {
4984                table: "catalogue_codes".to_owned(),
4985                values: Map::from_iter([
4986                    ("id".to_owned(), Value::from("c1")),
4987                    ("release_id".to_owned(), Value::from("r1")),
4988                    ("code".to_owned(), Value::from("A01")),
4989                    ("title".to_owned(), Value::from("Enteric infection")),
4990                ]),
4991                base_version: None,
4992            }])
4993            .expect("update code");
4994        assert!(search(&client, "cholera").is_empty());
4995        assert_eq!(search(&client, "enteric").len(), 1);
4996
4997        client
4998            .mutate(vec![Mutation::Delete {
4999                table: "catalogue_codes".to_owned(),
5000                row_id: "c1".to_owned(),
5001                base_version: None,
5002            }])
5003            .expect("delete code");
5004        assert!(search(&client, "enteric").is_empty());
5005    }
5006
5007    #[test]
5008    fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
5009        let schema = json!({
5010            "version": 1,
5011            "tables": [{
5012                "name": "patient_notes",
5013                "primaryKey": "id",
5014                "columns": [
5015                    { "name": "id", "type": "string", "nullable": false },
5016                    { "name": "practice_id", "type": "string", "nullable": false },
5017                    { "name": "encryption_key_id", "type": "string", "nullable": false },
5018                    { "name": "title", "type": "string", "nullable": false }
5019                ],
5020                "scopes": [{ "pattern": "practice:{practice_id}" }],
5021                "ftsIndexes": [{
5022                    "name": "patient_notes_fts",
5023                    "columns": ["title"],
5024                    "tokenize": "unicode61 remove_diacritics 2"
5025                }]
5026            }]
5027        });
5028        let mut client = SyncClient::new(
5029            "local-purge-test".to_owned(),
5030            &schema,
5031            ClientLimits::default(),
5032        )
5033        .expect("local purge client");
5034        client
5035            .conn
5036            .execute(
5037                "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
5038                 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
5039                 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
5040                [],
5041            )
5042            .expect("seed base rows");
5043        client.overlay_dirty.set(true);
5044        client.rebuild_overlay_if_dirty();
5045
5046        let note = |id: &str, key_id: &str, title: &str| {
5047            Map::from_iter([
5048                ("id".to_owned(), Value::from(id)),
5049                ("practice_id".to_owned(), Value::from("practice-1")),
5050                ("encryption_key_id".to_owned(), Value::from(key_id)),
5051                ("title".to_owned(), Value::from(title)),
5052            ])
5053        };
5054        let doomed = client
5055            .mutate(vec![
5056                Mutation::Upsert {
5057                    table: "patient_notes".to_owned(),
5058                    values: note("target", "key-revoked", "Target changed"),
5059                    base_version: None,
5060                },
5061                Mutation::Upsert {
5062                    table: "patient_notes".to_owned(),
5063                    values: note("unrelated", "key-held", "Unrelated changed"),
5064                    base_version: None,
5065                },
5066            ])
5067            .expect("doomed commit");
5068        let kept = client
5069            .mutate(vec![Mutation::Upsert {
5070                table: "patient_notes".to_owned(),
5071                values: note("kept", "key-held", "Kept optimistic"),
5072                base_version: None,
5073            }])
5074            .expect("kept commit");
5075        client.drain_change_batches();
5076
5077        let input = LocalDataPurgeInput {
5078            purge_id: "purge-001".to_owned(),
5079            targets: vec![LocalDataPurgeTarget {
5080                table: "patient_notes".to_owned(),
5081                selectors: BTreeMap::from([(
5082                    "encryption_key_id".to_owned(),
5083                    vec!["key-revoked".to_owned()],
5084                )]),
5085            }],
5086        };
5087        assert_eq!(
5088            client.purge_local_data(&input).expect("apply purge"),
5089            LocalDataPurgeResult {
5090                already_applied: false,
5091                purged_rows: 1,
5092                dropped_commits: 1,
5093            }
5094        );
5095        let rows = client
5096            .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
5097            .expect("visible rows");
5098        assert_eq!(rows.len(), 2);
5099        assert_eq!(rows[0]["id"], "kept");
5100        assert_eq!(rows[1]["id"], "unrelated");
5101        assert_eq!(rows[1]["title"], "Unrelated original");
5102        let fts = client
5103            .query(
5104                "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'",
5105                &[],
5106            )
5107            .expect("fts query");
5108        assert!(fts.is_empty());
5109        assert_eq!(client.outbox.len(), 1);
5110        assert_eq!(client.outbox[0].client_commit_id, kept);
5111        let outcome = client
5112            .commit_outcome(&doomed)
5113            .expect("read doomed outcome")
5114            .expect("doomed outcome");
5115        assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
5116        match &outcome.results[0] {
5117            CommitOperationOutcome::Error { rejection } => {
5118                assert_eq!(rejection.code, "client.local_data_purged");
5119                assert_eq!(rejection.client_commit_id, doomed);
5120            }
5121            other => panic!("expected local purge rejection, got {other:?}"),
5122        }
5123        assert_eq!(client.drain_change_batches().len(), 1);
5124        assert_eq!(
5125            client.purge_local_data(&input).expect("retry purge"),
5126            LocalDataPurgeResult {
5127                already_applied: true,
5128                purged_rows: 0,
5129                dropped_commits: 0,
5130            }
5131        );
5132        let conflicting = LocalDataPurgeInput {
5133            purge_id: input.purge_id.clone(),
5134            targets: vec![LocalDataPurgeTarget {
5135                table: "patient_notes".to_owned(),
5136                selectors: BTreeMap::from([(
5137                    "encryption_key_id".to_owned(),
5138                    vec!["key-held".to_owned()],
5139                )]),
5140            }],
5141        };
5142        assert!(client
5143            .purge_local_data(&conflicting)
5144            .expect_err("id collision must fail")
5145            .contains("already used with a different plan"));
5146    }
5147}
5148
5149impl SubState {
5150    fn name(self) -> &'static str {
5151        match self {
5152            SubState::Active => "active",
5153            SubState::Revoked => "revoked",
5154            SubState::Failed => "failed",
5155        }
5156    }
5157
5158    fn parse(value: &str) -> Self {
5159        match value {
5160            "revoked" => Self::Revoked,
5161            "failed" => Self::Failed,
5162            _ => Self::Active,
5163        }
5164    }
5165}
5166
5167#[derive(Debug, Clone)]
5168struct Subscription {
5169    id: String,
5170    table: String,
5171    requested: Vec<(String, Vec<String>)>,
5172    params: Option<String>,
5173    cursor: i64,
5174    /// §4.7 resume token, round-tripped opaquely.
5175    bootstrap_state: Option<String>,
5176    state: SubState,
5177    reason_code: Option<String>,
5178    /// Last effective scopes echoed while active (§3.3: persisted for the
5179    /// purge contract; each active echo replaces it).
5180    effective: Option<Vec<(String, Vec<String>)>>,
5181    synced_once: bool,
5182}
5183
5184#[derive(Debug, Clone)]
5185struct OutboxOp {
5186    upsert: bool,
5187    table: String,
5188    row_id: String,
5189    base_version: Option<i64>,
5190    /// Schema-agnostic local form (§0): driver JSON values, encoded with
5191    /// the current codec at send time. A key's presence IS the §6.1 sparse
5192    /// presence set — both the overlay and the wire payload derive from it.
5193    values: Option<Map<String, Value>>,
5194}
5195
5196impl From<&OutboxOp> for CommitOperation {
5197    fn from(operation: &OutboxOp) -> Self {
5198        Self {
5199            table: operation.table.clone(),
5200            row_id: operation.row_id.clone(),
5201            op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
5202            base_version: operation.base_version,
5203            values: operation.values.clone(),
5204        }
5205    }
5206}
5207
5208#[derive(Debug, Clone)]
5209struct OutboxCommit {
5210    client_commit_id: String,
5211    ops: Vec<OutboxOp>,
5212}
5213
5214/// One commit's `(table, value keys)` per upsert operation — the classification
5215/// shape the §7.4.4 send-time drop and the RFC 0005 D6 audit share, so the two
5216/// can never disagree about which commits cannot re-encode.
5217fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>)> {
5218    commit
5219        .ops
5220        .iter()
5221        .filter(|operation| operation.upsert)
5222        .map(|operation| {
5223            (
5224                operation.table.as_str(),
5225                operation
5226                    .values
5227                    .as_ref()
5228                    .map(|values| values.keys().map(String::as_str).collect())
5229                    .unwrap_or_default(),
5230            )
5231        })
5232        .collect()
5233}
5234
5235type FailedCommit = (OutboxCommit, Map<String, Value>);
5236
5237#[derive(Debug, Clone)]
5238struct CompiledLocalDataPurgeTarget {
5239    table: String,
5240    selectors: Vec<(String, Vec<String>)>,
5241}
5242
5243struct StoredCommitOutcomeRow {
5244    sequence: i64,
5245    client_commit_id: String,
5246    status: String,
5247    recorded_at_ms: i64,
5248    results_json: String,
5249    operations_json: Option<String>,
5250    resolution: String,
5251    resolved_at_ms: Option<i64>,
5252    replacement_client_commit_id: Option<String>,
5253}
5254
5255/// Section outcome distinguishing the §5.6 subscription-local fail-closed
5256/// path from a round-aborting failure (§1.4 rule 5).
5257enum SectionError {
5258    FailClosed,
5259    Abort(String, String),
5260}
5261
5262pub(crate) struct RequestMeta {
5263    pushed_ids: Vec<String>,
5264    /// Subscription id → the request carried `cursor < 0` and no resume
5265    /// token (§5.6 first-page detection: a *fresh* bootstrap).
5266    fresh: Vec<(String, bool)>,
5267    pub(crate) accept: u8,
5268    /// §6.1 splitBatch: outbox commits held back from THIS request because
5269    /// the running operation count reached the push cap — the next round
5270    /// pushes them (`sync_needed` stays set while any remain).
5271    deferred_commits: usize,
5272}
5273
5274#[derive(Default)]
5275struct ChangeAccumulator {
5276    /// None means table-wide; Some is the exact scoped domain.
5277    tables: BTreeMap<String, Option<BTreeSet<String>>>,
5278    windows: BTreeMap<(String, String), BTreeSet<String>>,
5279    status: bool,
5280    conflicts: bool,
5281    rejections: bool,
5282    outcomes: bool,
5283}
5284
5285impl ChangeAccumulator {
5286    fn table(&mut self, table: &str) {
5287        self.tables.insert(table.to_owned(), None);
5288    }
5289
5290    fn scope(&mut self, table: &str, key: String) {
5291        match self.tables.get_mut(table) {
5292            Some(None) => {}
5293            Some(Some(keys)) => {
5294                keys.insert(key);
5295            }
5296            None => {
5297                self.tables
5298                    .insert(table.to_owned(), Some(BTreeSet::from([key])));
5299            }
5300        }
5301    }
5302
5303    fn window(&mut self, base_key: &str, table: &str, unit: &str) {
5304        self.windows
5305            .entry((base_key.to_owned(), table.to_owned()))
5306            .or_default()
5307            .insert(unit.to_owned());
5308    }
5309
5310    fn touched(&self) -> bool {
5311        !self.tables.is_empty()
5312            || !self.windows.is_empty()
5313            || self.status
5314            || self.conflicts
5315            || self.rejections
5316            || self.outcomes
5317    }
5318}
5319
5320/// §6.1: the server caps total operations per request (reference default
5321/// 500) and rejects the whole batch with `sync.too_many_operations`; the
5322/// client "splits and retries". Splitting happens at build time: commits are
5323/// included IN ORDER until the operation budget is spent, the rest wait for
5324/// the next round.
5325const PUSH_OPS_PER_REQUEST: usize = 500;
5326const MAX_LOCAL_PURGE_TARGETS: usize = 64;
5327const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
5328const MAX_LOCAL_PURGE_VALUES: usize = 128;
5329const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
5330pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
5331
5332/// Dirty visible tables; `None` means a whole-replica recovery/reset.
5333struct OverlayDirty(RefCell<Option<BTreeSet<String>>>);
5334
5335impl OverlayDirty {
5336    fn clean() -> Self {
5337        Self(RefCell::new(Some(BTreeSet::new())))
5338    }
5339    fn get(&self) -> bool {
5340        self.0
5341            .borrow()
5342            .as_ref()
5343            .is_none_or(|tables| !tables.is_empty())
5344    }
5345    fn set(&self, dirty: bool) {
5346        *self.0.borrow_mut() = if dirty { None } else { Some(BTreeSet::new()) };
5347    }
5348    fn table(&self, table: &str) {
5349        if let Some(tables) = self.0.borrow_mut().as_mut() {
5350            tables.insert(table.to_owned());
5351        }
5352    }
5353    fn snapshot(&self) -> Option<BTreeSet<String>> {
5354        self.0.borrow().clone()
5355    }
5356    fn restore(&self, tables: Option<BTreeSet<String>>) {
5357        *self.0.borrow_mut() = tables;
5358    }
5359}
5360
5361pub struct SyncClient {
5362    #[cfg(feature = "bench-internals")]
5363    benchmark_phases: Recorder,
5364    conn: Connection,
5365    schema: ClientSchema,
5366    client_id: String,
5367    limits: ClientLimits,
5368    subs: Vec<Subscription>,
5369    outbox: Vec<OutboxCommit>,
5370    failed_commits: Vec<FailedCommit>,
5371    retain_failed_commits: bool,
5372    conflicts: Vec<ConflictRecord>,
5373    rejections: Vec<RejectionRecord>,
5374    schema_floor: Option<SchemaFloor>,
5375    /// §7.3.5: the opaque auth-lease state (from LEASE frames + lease errors).
5376    lease_state: Option<LeaseState>,
5377    /// §1.6: the schema-floor response stops syncing until an upgrade.
5378    stopped: bool,
5379    /// §7.4.5: true while a schema-bump reset + first re-bootstrap is in flight.
5380    upgrading: bool,
5381    /// §8.4 coalesced sync-needed signal.
5382    sync_needed: bool,
5383    /// §8.8: how sync rounds treat the realtime binding, and its exact
5384    /// availability state with the evidence behind a lost or refused one.
5385    realtime_policy: RealtimePolicy,
5386    realtime_state: RealtimeState,
5387    realtime_reason_code: Option<String>,
5388    realtime_retry_delay_ms: Option<u64>,
5389    /// §8.6 presence: scopeKey → (`actorId clientId` peer key → peer).
5390    presence: HashMap<String, HashMap<String, PresencePeer>>,
5391    /// Client clock (epoch ms) for the §5.4 `urlExpiresAtMs` check; the
5392    /// host may pin it (conformance runs on a virtual clock).
5393    now_ms: Option<i64>,
5394    /// §5.11 client-side encryption keys (`keyId → key bytes`). Empty ⇒ E2EE
5395    /// off. The encrypt/decrypt seam (`values.rs`) is compiled only under the
5396    /// `e2ee` feature; without it, a schema with encrypted columns fails loud.
5397    encryption: crate::values::EncryptionConfig,
5398    /// Fail-closed host bootstrap gate. While set, command hosts permit only
5399    /// status/lifecycle inspection and an exact authorized local purge.
5400    security_preflight: bool,
5401    /// RFC 0005 D2/D8: the previous-version capture config. `None` (default)
5402    /// means the feature is off; the descriptor write and the container orphan
5403    /// sweep still happen.
5404    previous_version: Option<PreviousVersionContextConfig>,
5405    /// Per-table primary-key upsert SQL, built once per (full table name) —
5406    /// the row write path runs per row during bootstrap (§5.6), so the SQL
5407    /// string (and, via `prepare_cached`, its compiled statement) is reused
5408    /// instead of being rebuilt and re-prepared per row. Cleared on a §7.4.3
5409    /// schema reset (the column lists may have changed).
5410    insert_sql: RefCell<HashMap<String, String>>,
5411    /// §7.1: reconcile only tables whose base/outbox changed. Whole-replica
5412    /// reset and restart explicitly invalidate every table.
5413    overlay_dirty: OverlayDirty,
5414    // First SQLite failure of the current operation, before savepoint cleanup.
5415    storage_failure: RefCell<Option<QueryReadFailure>>,
5416    /// Test-only structural performance signal: response processing must not
5417    /// turn a batch of acknowledgements into one full overlay rebuild each.
5418    #[cfg(test)]
5419    overlay_rebuild_count: Cell<usize>,
5420    /// Test-only structural performance signal: outcome retention is enforced
5421    /// once per response rather than once per acknowledgement.
5422    #[cfg(test)]
5423    outcome_prune_count: Cell<usize>,
5424    /// Exact observer-transaction output drained by command/FFI hosts.
5425    progress: ProgressObserver,
5426    change_queue: VecDeque<ClientChangeBatch>,
5427    sync_intent_queue: VecDeque<SyncIntent>,
5428    /// Explicit exponential retry policy for transient transport failures.
5429    retry_delay_ms: u64,
5430    /// §7.6: the background retry the current round scheduled, if any.
5431    round_retry_delay_ms: Option<u64>,
5432    active_round: Option<uuid::Uuid>,
5433    last_round: Option<DiagnosticLastRound>,
5434    last_change: Option<DiagnosticLastChange>,
5435    /// §7.6: latest failed owned snapshot read per owner id, oldest first.
5436    query_failures: Vec<DiagnosticQueryFailure>,
5437}
5438
5439pub(crate) fn quote_ident(name: &str) -> String {
5440    format!("\"{}\"", name.replace('"', "\"\""))
5441}
5442/// §7.4.1 `_syncular_meta` helpers. Free functions so RFC 0005's
5443/// previous-version module reads and writes the SAME records as the client
5444/// instead of growing a parallel meta layer.
5445pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
5446    conn.query_row(
5447        "SELECT value FROM _syncular_meta WHERE key = ?1",
5448        rusqlite::params![key],
5449        |row| row.get::<_, String>(0),
5450    )
5451    .ok()
5452}
5453
5454pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
5455    let _ = conn.execute(
5456        "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
5457        rusqlite::params![key, value],
5458    );
5459}
5460
5461pub(crate) fn meta_delete(conn: &Connection, key: &str) {
5462    let _ = conn.execute(
5463        "DELETE FROM _syncular_meta WHERE key = ?1",
5464        rusqlite::params![key],
5465    );
5466}
5467
5468/// Seek by the existing key while retaining the exact legacy text match.
5469fn row_id_predicate(table: &TableSchema) -> String {
5470    let key = quote_ident(&table.primary_key);
5471    let text_match = format!("CAST({key} AS TEXT) = ?1");
5472    match table.columns[table.pk_index].ty {
5473        ColumnType::String | ColumnType::Json => {
5474            format!("{key} = ?1 AND {text_match}")
5475        }
5476        // Native columns have no declared SQLite affinity. Match the stored
5477        // integer bind type explicitly. Unary + removes expression affinity so
5478        // SQLite can seek the typeless key; the text check preserves exact IDs.
5479        ColumnType::Integer | ColumnType::Boolean => {
5480            format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
5481        }
5482        // REAL-to-text conversion can round distinct values to the same text.
5483        // Narrowing those matches by numeric equality would change behavior.
5484        _ => text_match,
5485    }
5486}
5487
5488fn is_local_operation_code_like(value: &str) -> bool {
5489    let bytes = value.as_bytes();
5490    bytes.first().is_some_and(u8::is_ascii_alphanumeric)
5491        && bytes
5492            .iter()
5493            .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
5494}
5495
5496fn base_table(name: &str) -> String {
5497    quote_ident(&format!("_syncular_base_{name}"))
5498}
5499
5500/// §4.8: a stable, server-opaque key for a window base — table + variable +
5501/// canonical fixed scopes. Two `set_window` calls with the same base
5502/// address the same registry rows.
5503/// §4.8 deferred eviction record: (sub id, table, effective scope map).
5504type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
5505
5506fn window_base_key(base: &WindowBase) -> String {
5507    format!(
5508        "{}\0{}\0{}",
5509        base.table,
5510        base.variable,
5511        canonical_scope_json(&base.fixed_scopes)
5512    )
5513}
5514
5515/// §4.8: the full requested scope map for one unit (fixed scopes + unit).
5516fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
5517    let mut scopes = base.fixed_scopes.clone();
5518    scopes.retain(|(k, _)| k != &base.variable);
5519    scopes.push((base.variable.clone(), vec![unit.to_owned()]));
5520    scopes
5521}
5522
5523/// §4.1 guidance: `w:<table>:<sha256(canonical scope map)[0..16]>`. Ids are
5524/// echoed not interpreted by the server, so the exact hash is client
5525/// convention; SHA-256 matches the SPEC's worked example.
5526fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
5527    let canonical = canonical_scope_json(&unit_scopes(base, unit));
5528    let digest = Sha256::digest(canonical.as_bytes());
5529    let hex = bytes_to_hex(&digest);
5530    format!("w:{}:{}", base.table, &hex[..16])
5531}
5532
5533fn visible_table(name: &str) -> String {
5534    quote_ident(name)
5535}
5536
5537const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
5538
5539/// §7.4.3: is a `sqlite_master` table name a synced table (visible or base),
5540/// i.e. NOT one of the durable bookkeeping tables the reset preserves?
5541fn is_synced_table_name(name: &str) -> bool {
5542    if name.starts_with("sqlite_") {
5543        return false;
5544    }
5545    if name.starts_with("_syncular_base_") {
5546        return true; // the base half of a synced table pair
5547    }
5548    // Bookkeeping: outbox, subscriptions, meta, blob cache/uploads.
5549    !name.starts_with("_syncular_")
5550}
5551
5552/// `"sha256:" + hex` of the bytes — the content address (§5.9.1).
5553fn blob_id_for(bytes: &[u8]) -> String {
5554    let digest = Sha256::digest(bytes);
5555    format!("sha256:{}", bytes_to_hex(&digest))
5556}
5557
5558/// One [`SyncClient::write_row`] bind parameter, borrowing the row-codec
5559/// value it wraps — strings/JSON/bytes bind as borrowed TEXT/BLOB (no copy
5560/// per row on the §5.6 bootstrap path), scalars bind owned.
5561enum RowParam<'a> {
5562    Cell(&'a Option<ColumnValue>),
5563    Version(i64),
5564}
5565
5566impl rusqlite::ToSql for RowParam<'_> {
5567    fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
5568        Ok(match self {
5569            RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
5570            RowParam::Cell(cell) => match cell {
5571                None => ToSqlOutput::Owned(SqlValue::Null),
5572                Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
5573                Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
5574                Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
5575                Some(ColumnValue::Boolean(b)) => {
5576                    ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
5577                }
5578                Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
5579                    ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
5580                }
5581                // §5.10: crdt bytes store as BLOB, like bytes.
5582                Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
5583                    ToSqlOutput::Borrowed(ValueRef::Blob(b))
5584                }
5585            },
5586        })
5587    }
5588}
5589
5590// Retain a typed primary key across the current import chunk without copying row payloads.
5591fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
5592    match param.to_sql().map_err(|error| error.to_string())? {
5593        ToSqlOutput::Owned(value) => Ok(value),
5594        ToSqlOutput::Borrowed(value) => {
5595            SqlValue::try_from(value).map_err(|error| error.to_string())
5596        }
5597        _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
5598    }
5599}
5600
5601/// §5.3 image cell → bind parameter, strict per the declared column type
5602/// (`boolean` from INTEGER 0/1, `json` from its raw TEXT, NULL only when
5603/// nullable). Mismatches are image-producer violations. Returns the cell
5604/// borrowed when the stored representation already matches what the row
5605/// codec would write, or the normalized scalar (boolean → 0/1, float from
5606/// INTEGER → REAL) otherwise — the local write is byte-identical to the
5607/// old convert-then-insert path without allocating per cell.
5608fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
5609    use ssp2::segment::ColumnType;
5610    let mismatch = || {
5611        Err(format!(
5612            "image column {:?} holds a value of the wrong type",
5613            column.name
5614        ))
5615    };
5616    match value {
5617        ValueRef::Null => {
5618            if !column.nullable {
5619                return Err(format!(
5620                    "image column {:?} is NULL but not nullable",
5621                    column.name
5622                ));
5623            }
5624            Ok(ToSqlOutput::Owned(SqlValue::Null))
5625        }
5626        ValueRef::Integer(i) => match column.ty {
5627            ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
5628            ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
5629            ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
5630            _ => mismatch(),
5631        },
5632        ValueRef::Real(_) => match column.ty {
5633            ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
5634            _ => mismatch(),
5635        },
5636        ValueRef::Text(t) => {
5637            std::str::from_utf8(t)
5638                .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
5639            match column.ty {
5640                ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
5641                    Ok(ToSqlOutput::Borrowed(value))
5642                }
5643                _ => mismatch(),
5644            }
5645        }
5646        ValueRef::Blob(_) => match column.ty {
5647            // §5.10: a crdt column stores its opaque bytes as BLOB, like bytes.
5648            ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
5649            _ => mismatch(),
5650        },
5651    }
5652}
5653
5654pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
5655    use rusqlite::types::ValueRef;
5656    match value {
5657        ValueRef::Null => Value::Null,
5658        ValueRef::Integer(i) => match column.ty {
5659            ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
5660            ssp2::segment::ColumnType::Float => {
5661                serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
5662            }
5663            _ => Value::from(i),
5664        },
5665        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
5666        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
5667        ValueRef::Blob(b) => {
5668            let mut map = Map::new();
5669            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
5670            Value::Object(map)
5671        }
5672    }
5673}
5674
5675/// Bind a driver JSON value form as a rusqlite parameter for [`SyncClient::query`].
5676/// Objects are accepted in lossless `{"$bytes": hex}` and
5677/// `{"$bigint": decimal}` envelope forms.
5678fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
5679    Ok(match value {
5680        Value::Null => SqlValue::Null,
5681        Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
5682        Value::Number(n) => {
5683            if let Some(i) = n.as_i64() {
5684                SqlValue::Integer(i)
5685            } else if let Some(f) = n.as_f64() {
5686                SqlValue::Real(f)
5687            } else {
5688                return Err(format!("query param number {n} is out of range"));
5689            }
5690        }
5691        Value::String(s) => SqlValue::Text(s.clone()),
5692        Value::Object(_) => {
5693            if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
5694                SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
5695            } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
5696                SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
5697                    format!("query bigint param {decimal:?} is outside SQLite's i64 range")
5698                })?)
5699            } else {
5700                return Err(
5701                    "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
5702                        .to_owned(),
5703                );
5704            }
5705        }
5706        Value::Array(_) => return Err("query array params are not supported".to_owned()),
5707    })
5708}
5709
5710/// Map a rusqlite value with no schema column to consult (arbitrary query
5711/// output): integers/reals/text pass through by stored affinity, blobs ride
5712/// as `{"$bytes": hex}`. Distinct from [`sql_ref_to_json`], which uses the
5713/// schema column type to recover booleans/floats/json.
5714fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
5715    use rusqlite::types::ValueRef;
5716    match value {
5717        ValueRef::Null => Value::Null,
5718        ValueRef::Integer(i) => {
5719            // JSON/Tauri IPC cannot represent every SQLite i64 exactly. Keep
5720            // ordinary UI-sized integers ergonomic and envelope only values
5721            // beyond JavaScript's safe range.
5722            const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
5723            if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
5724                Value::from(i)
5725            } else {
5726                let mut map = Map::new();
5727                map.insert("$bigint".to_owned(), Value::from(i.to_string()));
5728                Value::Object(map)
5729            }
5730        }
5731        ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
5732        ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
5733        ValueRef::Blob(b) => {
5734            let mut map = Map::new();
5735            map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
5736            Value::Object(map)
5737        }
5738    }
5739}
5740
5741/// §7.5: SQLite failures classify by result code into [`QueryReadFailure`].
5742fn query_connection(
5743    conn: &Connection,
5744    sql: &str,
5745    params: &[QueryValue],
5746) -> Result<Vec<QueryRow>, QueryReadFailure> {
5747    crate::query_guard::assert_read_only_query(sql)?;
5748    let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
5749    let bound: Vec<SqlValue> = params
5750        .iter()
5751        .map(json_param_to_sql)
5752        .collect::<Result<_, _>>()?;
5753    let mut stmt = conn.prepare(&lowered_sql)?;
5754    let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
5755    let bound_refs: Vec<&dyn rusqlite::ToSql> =
5756        bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
5757    let mut sql_rows = stmt.query(bound_refs.as_slice())?;
5758    let mut out = Vec::new();
5759    while let Some(row) = sql_rows.next()? {
5760        let mut record = Map::new();
5761        for (i, name) in column_names.iter().enumerate() {
5762            let value = row.get_ref(i)?;
5763            record.insert(name.clone(), sql_ref_to_json_dynamic(value));
5764        }
5765        out.push(record);
5766    }
5767    Ok(out)
5768}
5769
5770fn persisted_window_state(
5771    conn: &Connection,
5772    base: &WindowBase,
5773) -> Result<WindowState, QueryReadFailure> {
5774    let mut stmt = conn.prepare(
5775        "SELECT windows.unit, subscriptions.state_json
5776           FROM _syncular_windows AS windows
5777           JOIN _syncular_subscriptions AS subscriptions
5778             ON subscriptions.id = windows.sub_id
5779          WHERE windows.base = ?1
5780          ORDER BY windows.unit ASC",
5781    )?;
5782    let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
5783        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5784    })?;
5785    let mut units = Vec::new();
5786    let mut pending = Vec::new();
5787    for row in rows {
5788        let (unit, raw) = row?;
5789        let state: Value = serde_json::from_str(&raw)
5790            .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
5791        let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
5792            || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
5793            || state
5794                .get("bootstrapState")
5795                .is_some_and(|value| !value.is_null());
5796        if is_pending {
5797            pending.push(unit.clone());
5798        }
5799        units.push(unit);
5800    }
5801    Ok(WindowState { units, pending })
5802}
5803
5804fn snapshot_connection(
5805    conn: &Connection,
5806    sql: &str,
5807    params: &[Value],
5808    coverage: &[WindowCoverage],
5809) -> Result<QuerySnapshot, QueryReadFailure> {
5810    conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
5811    let result: Result<QuerySnapshot, QueryReadFailure> = (|| {
5812        let revision = conn
5813            .query_row(
5814                "SELECT value FROM _syncular_meta WHERE key = ?1",
5815                rusqlite::params![LOCAL_REVISION_KEY],
5816                |row| row.get::<_, String>(0),
5817            )
5818            .ok()
5819            .and_then(|value| value.parse::<u64>().ok())
5820            .unwrap_or(0);
5821        let rows = query_connection(conn, sql, params)?;
5822        let mut pending = Vec::new();
5823        let mut missing = Vec::new();
5824        for requested in coverage {
5825            let base_key = window_base_key(&requested.base);
5826            let state = persisted_window_state(conn, &requested.base)?;
5827            for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
5828                let reference = WindowUnitRef {
5829                    base_key: base_key.clone(),
5830                    unit: unit.clone(),
5831                };
5832                if !state.units.iter().any(|held| held == &unit) {
5833                    missing.push(reference);
5834                } else if state.pending.iter().any(|held| held == &unit) {
5835                    pending.push(reference);
5836                }
5837            }
5838        }
5839        Ok(QuerySnapshot {
5840            revision: revision.to_string(),
5841            rows,
5842            coverage: CoverageSnapshot {
5843                complete: pending.is_empty() && missing.is_empty(),
5844                pending,
5845                missing,
5846            },
5847        })
5848    })();
5849    match result {
5850        Ok(snapshot) => {
5851            conn.execute_batch("RELEASE syncular_snapshot_read")?;
5852            Ok(snapshot)
5853        }
5854        Err(mut error) => {
5855            if let Err(rollback) = conn
5856                .execute_batch("ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read")
5857            {
5858                error.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
5859            }
5860            Err(error)
5861        }
5862    }
5863}
5864
5865/// A long-lived read-only SQLite sidecar for latency-critical native views.
5866/// Network rounds stay serialized on the mutable core owner, while atomic
5867/// query snapshots use this independent connection and therefore never queue
5868/// behind HTTP/WebSocket latency.
5869pub struct FileQuerySnapshotReader {
5870    path: String,
5871    conn: Option<Connection>,
5872}
5873
5874impl FileQuerySnapshotReader {
5875    #[must_use]
5876    pub fn new(path: impl Into<String>) -> Self {
5877        Self {
5878            path: path.into(),
5879            conn: None,
5880        }
5881    }
5882
5883    fn connection(&mut self) -> Result<&Connection, String> {
5884        if self.conn.is_none() {
5885            let conn = Connection::open_with_flags(
5886                &self.path,
5887                OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
5888            )
5889            .map_err(|error| format!("open read sidecar {:?}: {error}", self.path))?;
5890            conn.busy_timeout(std::time::Duration::from_millis(250))
5891                .map_err(|error| error.to_string())?;
5892            self.conn = Some(conn);
5893        }
5894        self.conn
5895            .as_ref()
5896            .ok_or_else(|| "read sidecar connection missing".to_owned())
5897    }
5898
5899    /// Hosts report an owned failure, and the first success after it, to the
5900    /// owning core through [`SyncClient::record_query_read`] (§7.6).
5901    pub fn query_snapshot(
5902        &mut self,
5903        sql: &str,
5904        params: &[Value],
5905        coverage: &[WindowCoverage],
5906    ) -> Result<QuerySnapshot, QueryReadFailure> {
5907        snapshot_connection(self.connection()?, sql, params, coverage)
5908    }
5909}
5910
5911impl SyncClient {
5912    pub fn new_with_identity(
5913        client_id: Option<String>,
5914        schema_json: &Value,
5915        limits: ClientLimits,
5916    ) -> Result<Self, String> {
5917        let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
5918        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
5919        Self::with_connection(resolved, schema_json, limits, conn)
5920    }
5921
5922    pub fn new(
5923        client_id: String,
5924        schema_json: &Value,
5925        limits: ClientLimits,
5926    ) -> Result<Self, String> {
5927        let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
5928        Self::with_connection(client_id, schema_json, limits, conn)
5929    }
5930
5931    /// Build a client backed by an on-disk SQLite database at `path` — the
5932    /// seam a native host (Tauri plugin, FFI file-DB variant) uses to persist
5933    /// across process restarts. `create_tables` runs `IF NOT EXISTS`, so
5934    /// re-opening the same file reuses the persisted rows. Keeps rusqlite out
5935    /// of the command router's dependency set (the router only holds a path).
5936    pub fn open_path(
5937        client_id: String,
5938        schema_json: &Value,
5939        limits: ClientLimits,
5940        path: &str,
5941    ) -> Result<Self, String> {
5942        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
5943        Self::with_connection(client_id, schema_json, limits, conn)
5944    }
5945
5946    pub fn open_path_with_identity(
5947        client_id: Option<String>,
5948        schema_json: &Value,
5949        limits: ClientLimits,
5950        path: &str,
5951    ) -> Result<Self, String> {
5952        let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
5953        // File-backed native clients use an independent read connection for
5954        // latency-critical snapshots. WAL is SQLite's intended reader/writer
5955        // concurrency mode: a view read never holds a rollback-journal lock
5956        // that delays the mutable client's next commit, and a short busy
5957        // timeout absorbs the tiny checkpoint/schema-lock windows.
5958        conn.busy_timeout(std::time::Duration::from_millis(250))
5959            .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
5960        conn.pragma_update(None, "journal_mode", "WAL")
5961            .map_err(|error| format!("configure db {path:?} WAL mode: {error}"))?;
5962        let persisted = conn
5963            .query_row(
5964                "SELECT value FROM _syncular_meta WHERE key = 'clientId'",
5965                [],
5966                |row| row.get::<_, String>(0),
5967            )
5968            .ok();
5969        let resolved = persisted
5970            .clone()
5971            .or(client_id.clone())
5972            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
5973        if let (Some(existing), Some(requested)) = (persisted, client_id) {
5974            if existing != requested {
5975                return Err(format!(
5976                    "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
5977                ));
5978            }
5979        }
5980        Self::with_connection(resolved, schema_json, limits, conn)
5981    }
5982
5983    #[must_use]
5984    pub fn client_id(&self) -> &str {
5985        &self.client_id
5986    }
5987
5988    /// Build a client over a caller-supplied rusqlite connection — the seam a
5989    /// native host (Tauri plugin, FFI file-DB variant) uses to back the core
5990    /// with an on-disk database (`Connection::open(path)`) rather than the
5991    /// default `:memory:`. The connection MUST be fresh (no pre-existing
5992    /// syncular tables); `create_tables` runs `IF NOT EXISTS`, so re-opening
5993    /// the same file across process restarts reuses the persisted rows.
5994    pub fn with_connection(
5995        client_id: String,
5996        schema_json: &Value,
5997        limits: ClientLimits,
5998        conn: Connection,
5999    ) -> Result<Self, String> {
6000        if limits.outcome_retention_max_entries == Some(0) {
6001            return Err(
6002                "sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned(),
6003            );
6004        }
6005        let schema = parse_schema_json(schema_json)?;
6006        // RFC 0005 D8: resolve the previous-version config before the opening
6007        // reset runs — the capture happens inside it. Bad bounds fail loud.
6008        let previous_version = match limits.previous_version_context {
6009            Some(config) => {
6010                config.validate()?;
6011                Some(config)
6012            }
6013            None => None,
6014        };
6015        let mut client = SyncClient {
6016            #[cfg(feature = "bench-internals")]
6017            benchmark_phases: Recorder::default(),
6018            conn,
6019            schema,
6020            client_id,
6021            limits,
6022            subs: Vec::new(),
6023            outbox: Vec::new(),
6024            failed_commits: Vec::new(),
6025            retain_failed_commits: false,
6026            conflicts: Vec::new(),
6027            rejections: Vec::new(),
6028            schema_floor: None,
6029            lease_state: None,
6030            stopped: false,
6031            upgrading: false,
6032            sync_needed: false,
6033            realtime_policy: RealtimePolicy::Optional,
6034            realtime_state: RealtimeState::Disconnected,
6035            realtime_reason_code: None,
6036            realtime_retry_delay_ms: None,
6037            presence: HashMap::new(),
6038            now_ms: None,
6039            encryption: crate::values::EncryptionConfig::default(),
6040            security_preflight: false,
6041            previous_version,
6042            insert_sql: RefCell::new(HashMap::new()),
6043            overlay_dirty: OverlayDirty::clean(),
6044            storage_failure: RefCell::new(None),
6045            #[cfg(test)]
6046            overlay_rebuild_count: Cell::new(0),
6047            #[cfg(test)]
6048            outcome_prune_count: Cell::new(0),
6049            progress: ProgressObserver::default(),
6050            change_queue: VecDeque::new(),
6051            sync_intent_queue: VecDeque::new(),
6052            retry_delay_ms: 250,
6053            round_retry_delay_ms: None,
6054            active_round: None,
6055            last_round: None,
6056            last_change: None,
6057            query_failures: Vec::new(),
6058        };
6059        // The row write path leans on the prepared-statement cache (two
6060        // insert statements per synced table, plus the bookkeeping
6061        // statements); size it so a multi-table schema never thrashes.
6062        client
6063            .conn
6064            .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
6065        // Protected bookkeeping must exist before inspecting the persisted
6066        // schema marker. New app indexes may reference columns that only
6067        // exist after a version-bump reset.
6068        client.create_bookkeeping_tables()?;
6069        match client.get_meta(CLIENT_ID_KEY) {
6070            Some(existing) if existing != client.client_id => {
6071                return Err(format!(
6072                    "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {:?}",
6073                    client.client_id
6074                ));
6075            }
6076            None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
6077            _ => {}
6078        }
6079        client.restore_persisted_state()?;
6080        client.failed_commits = client.load_failed_commits()?;
6081        let marker = client
6082            .get_meta(LOCAL_SCHEMA_VERSION_KEY)
6083            .and_then(|value| value.parse::<i32>().ok());
6084        match marker {
6085            None => {
6086                client.create_synced_tables()?;
6087                client.set_meta(LOCAL_SCHEMA_VERSION_KEY, &client.schema.version.to_string());
6088                // D1: the descriptor is written beside every marker write.
6089                set_local_schema_descriptor(&client.conn, &client.schema);
6090                client.save_subscription_scope_schema();
6091            }
6092            Some(version) if version == client.schema.version => {
6093                client.create_synced_tables()?;
6094                // RFC 0005 D1: the same-version open backfills the descriptor
6095                // for a database first opened by an unaware binary, so the
6096                // NEXT bump can capture without needing a schema bump first.
6097                set_local_schema_descriptor(&client.conn, &client.schema);
6098                client.save_subscription_scope_schema();
6099            }
6100            Some(_) => client.run_schema_reset()?,
6101        }
6102        // RFC 0005 D9: the reset sweep cannot see a container whose capture
6103        // committed in its OWN file while the replica's savepoint never
6104        // released — a crash at that seam leaves a container beside a matching
6105        // OLD marker, and the next open at that version runs no reset. Discard
6106        // it before anything can read it. No-op when no container exists.
6107        client.reconcile_previous_version_at_boot()?;
6108        client.clear_satisfied_persisted_schema_floor();
6109        client.prune_unknown_subscriptions(false)?;
6110        if marker == Some(client.schema.version)
6111            && (!client.outbox.is_empty()
6112                || !client.failed_commits.is_empty()
6113                || client.has_acknowledged_rows())
6114        {
6115            // Reconstruct the visible optimistic overlay from the durable base
6116            // plus outbox instead of trusting a process-interrupted mirror.
6117            client.overlay_dirty.set(true);
6118            client.rebuild_overlay();
6119        }
6120        // Every persisted active subscription needs one catch-up pull on open:
6121        // realtime only covers changes after connection, while an idempotent
6122        // setWindow correctly creates no fresh command effect. Pending outbox
6123        // work has the same restart requirement. The core owns this intent so
6124        // native hosts never poll or require an application-issued sync().
6125        client.enqueue_startup_sync_if_needed()?;
6126        Ok(client)
6127    }
6128
6129    /// Pin the client clock (epoch ms) — the §5.4 expiry check runs
6130    /// against this instead of system time (conformance virtual clock).
6131    pub fn set_now_ms(&mut self, now_ms: i64) {
6132        self.now_ms = Some(now_ms);
6133    }
6134
6135    /// §5.11: install the client-side encryption keys (`keyId → key bytes`).
6136    /// The command router parses these from the `create` command's
6137    /// `encryption` config (keys as `{$bytes: hex}`).
6138    pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
6139        self.encryption = encryption;
6140    }
6141
6142    #[must_use]
6143    pub fn security_lifecycle(&self) -> &'static str {
6144        if self.security_preflight {
6145            "preflight"
6146        } else {
6147            "active"
6148        }
6149    }
6150
6151    #[must_use]
6152    pub fn security_preflight(&self) -> bool {
6153        self.security_preflight
6154    }
6155
6156    /// Quarantine this replica: enter the fail-closed gate, release all
6157    /// core-owned key material, and record the gate in the database so
6158    /// reopening re-enters preflight until `activate_security` clears it.
6159    ///
6160    /// Use [`SyncClient::seal_security_on_teardown`] for the shutdown barrier,
6161    /// which must NOT leave that durable mark.
6162    pub fn begin_security_preflight(&mut self) {
6163        self.seal_security_on_teardown();
6164        // Persist the quarantine so it survives handle teardown and restart:
6165        // reopening this replica re-enters preflight until activation clears it.
6166        self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
6167    }
6168
6169    /// Release core-owned key material as a host tears the client down,
6170    /// WITHOUT recording a quarantine.
6171    ///
6172    /// Shutting an activated client down is not a quarantine event. Persisting
6173    /// the gate here marks every cleanly closed replica as pending, and the
6174    /// next plain `create` is then refused permanently: the reopen path
6175    /// restores the flag from the marker and the create guard rejects it. The
6176    /// in-memory flag still closes the gate for anything still holding this
6177    /// instance, which is all a teardown barrier needs.
6178    pub fn seal_security_on_teardown(&mut self) {
6179        self.cancel_sync_round();
6180        self.security_preflight = true;
6181        self.encryption = crate::values::EncryptionConfig::default();
6182        self.sync_intent_queue.clear();
6183    }
6184
6185    /// Install the post-authentication keyring and release the host loop.
6186    pub fn activate_security(
6187        &mut self,
6188        encryption: crate::values::EncryptionConfig,
6189    ) -> Result<(), String> {
6190        if !self.security_preflight {
6191            return Err(
6192                "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
6193            );
6194        }
6195        self.enqueue_startup_sync_if_needed()?;
6196        self.encryption = encryption;
6197        self.security_preflight = false;
6198        self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
6199        Ok(())
6200    }
6201
6202    fn clock_now_ms(&self) -> i64 {
6203        self.now_ms.unwrap_or_else(|| {
6204            std::time::SystemTime::now()
6205                .duration_since(std::time::UNIX_EPOCH)
6206                .map(|d| d.as_millis() as i64)
6207                .unwrap_or(0)
6208        })
6209    }
6210
6211    fn create_bookkeeping_tables(&self) -> Result<(), String> {
6212        // Durable client bookkeeping (outbox + subscription + meta).
6213        self.conn
6214            .execute_batch(
6215                "CREATE TABLE IF NOT EXISTS _syncular_outbox (
6216                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
6217                   commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
6218                 CREATE TABLE IF NOT EXISTS _syncular_row_deliveries (
6219                   tbl TEXT NOT NULL, id TEXT NOT NULL, commit_seq INTEGER NOT NULL, PRIMARY KEY(tbl,id));
6220                 CREATE TABLE IF NOT EXISTS _syncular_acked_rows (
6221                   commit_id TEXT NOT NULL, idx INTEGER NOT NULL, tbl TEXT NOT NULL, id TEXT NOT NULL,
6222                   commit_seq INTEGER NOT NULL, op_json TEXT NOT NULL, intent_json TEXT,
6223                   PRIMARY KEY(commit_id, idx));
6224                 CREATE TABLE IF NOT EXISTS _syncular_failed_commits (
6225                   seq INTEGER PRIMARY KEY AUTOINCREMENT, client_commit_id TEXT NOT NULL UNIQUE,
6226                   operations_json TEXT NOT NULL, initial_json TEXT NOT NULL);
6227                 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
6228                   seq INTEGER PRIMARY KEY AUTOINCREMENT,
6229                   client_commit_id TEXT NOT NULL UNIQUE,
6230                   status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
6231                   recorded_at_ms INTEGER NOT NULL,
6232                   results_json TEXT NOT NULL,
6233                   operations_json TEXT,
6234                   resolution TEXT NOT NULL DEFAULT 'active'
6235                     CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
6236                   resolved_at_ms INTEGER,
6237                   replacement_client_commit_id TEXT);
6238                 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
6239                   ON _syncular_commit_outcomes(resolution, seq);
6240                 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
6241                   id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
6242                 CREATE TABLE IF NOT EXISTS _syncular_meta (
6243                   key TEXT PRIMARY KEY, value TEXT NOT NULL);
6244                 CREATE TABLE IF NOT EXISTS _syncular_windows (
6245                   base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
6246                   PRIMARY KEY (base, unit));
6247                 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
6248                   sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
6249                   effective_scopes TEXT NOT NULL);
6250                 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
6251                   commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
6252                   PRIMARY KEY(commit_id, blob_id));
6253                 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
6254                   ON _syncular_blob_commit_refs(blob_id);",
6255            )
6256            .map_err(|e| e.to_string())?;
6257        // Migrate an outcome journal created before failed aggregate
6258        // envelopes were retained. Historical rows intentionally stay NULL.
6259        let _ = self
6260            .conn
6261            .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
6262        if self.get_meta(LOCAL_REVISION_KEY).is_none() {
6263            self.set_meta(LOCAL_REVISION_KEY, "0");
6264        }
6265        // §5.9.7 blob cache. Mutable upload state stays off the body row so
6266        // completing a large upload does not rewrite its BLOB record.
6267        if self.schema_has_blobs() {
6268            let exists = self
6269                .conn
6270                .query_row(
6271                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
6272                    [],
6273                    |row| row.get::<_, bool>(0),
6274                )
6275                .map_err(|e| e.to_string())?;
6276            if exists {
6277                let mut statement = self
6278                    .conn
6279                    .prepare("PRAGMA table_info(_syncular_blobs)")
6280                    .map_err(|e| e.to_string())?;
6281                let mut columns = statement
6282                    .query_map([], |row| row.get::<_, String>(1))
6283                    .map_err(|e| e.to_string())?
6284                    .collect::<Result<Vec<_>, _>>()
6285                    .map_err(|e| e.to_string())?;
6286                columns.sort();
6287                let mut expected = vec![
6288                    "blob_id",
6289                    "byte_length",
6290                    "bytes",
6291                    "created_at_ms",
6292                    "media_type",
6293                ];
6294                expected.sort_unstable();
6295                if columns != expected {
6296                    return Err(
6297                        "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
6298                    );
6299                }
6300            } else {
6301                self.conn
6302                    .execute_batch(
6303                        "CREATE TABLE _syncular_blobs(
6304                           blob_id TEXT PRIMARY KEY,
6305                           bytes BLOB NOT NULL,
6306                           byte_length INTEGER NOT NULL,
6307                           media_type TEXT,
6308                           created_at_ms INTEGER NOT NULL);",
6309                    )
6310                    .map_err(|e| e.to_string())?;
6311            }
6312            self.conn
6313                .execute_batch(
6314                    "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
6315                       blob_id TEXT PRIMARY KEY,
6316                       media_type TEXT,
6317                       created_at_ms INTEGER NOT NULL);",
6318                )
6319                .map_err(|e| e.to_string())?;
6320        }
6321        Ok(())
6322    }
6323
6324    /// True iff any synced table declares a `blob_ref` column (§5.9).
6325    fn schema_has_blobs(&self) -> bool {
6326        self.schema
6327            .tables
6328            .iter()
6329            .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
6330    }
6331
6332    // -- meta (§7.4.1 marker, bookkeeping) ------------------------------------
6333
6334    fn get_meta(&self, key: &str) -> Option<String> {
6335        meta_get(&self.conn, key)
6336    }
6337
6338    fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
6339        self.conn
6340            .query_row(
6341                "SELECT value FROM _syncular_meta WHERE key = ?1",
6342                rusqlite::params![key],
6343                |row| row.get::<_, String>(0),
6344            )
6345            .optional()
6346            .map_err(|_| {
6347                "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
6348            })
6349    }
6350
6351    fn set_meta(&self, key: &str, value: &str) {
6352        meta_set(&self.conn, key, value);
6353    }
6354
6355    fn delete_meta(&self, key: &str) {
6356        meta_delete(&self.conn, key);
6357    }
6358
6359    fn restore_persisted_state(&mut self) -> Result<(), String> {
6360        self.subs = {
6361            let mut stmt = self
6362                .conn
6363                .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
6364                .map_err(|error| error.to_string())?;
6365            let rows = stmt
6366                .query_map([], |row| {
6367                    Ok((
6368                        row.get::<_, String>(0)?,
6369                        row.get::<_, String>(1)?,
6370                        row.get::<_, String>(2)?,
6371                    ))
6372                })
6373                .map_err(|error| error.to_string())?;
6374            let mut subscriptions = Vec::new();
6375            for row in rows {
6376                let (id, table, raw) = row.map_err(|error| error.to_string())?;
6377                let state: Value = serde_json::from_str(&raw)
6378                    .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
6379                let requested = json_to_scope_map(
6380                    state.get("requested").unwrap_or(&Value::Object(Map::new())),
6381                )?;
6382                let effective = state
6383                    .get("effectiveScopes")
6384                    .filter(|value| !value.is_null())
6385                    .map(json_to_scope_map)
6386                    .transpose()?;
6387                subscriptions.push(Subscription {
6388                    id,
6389                    table,
6390                    requested,
6391                    params: state
6392                        .get("params")
6393                        .and_then(Value::as_str)
6394                        .map(str::to_owned),
6395                    cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
6396                    bootstrap_state: state
6397                        .get("bootstrapState")
6398                        .and_then(Value::as_str)
6399                        .map(str::to_owned),
6400                    state: SubState::parse(
6401                        state
6402                            .get("status")
6403                            .and_then(Value::as_str)
6404                            .unwrap_or("active"),
6405                    ),
6406                    reason_code: state
6407                        .get("reasonCode")
6408                        .and_then(Value::as_str)
6409                        .map(str::to_owned),
6410                    effective,
6411                    synced_once: state
6412                        .get("syncedOnce")
6413                        .and_then(Value::as_bool)
6414                        .unwrap_or(false),
6415                });
6416            }
6417            subscriptions
6418        };
6419
6420        self.outbox = {
6421            let mut stmt = self
6422                .conn
6423                .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
6424                .map_err(|error| error.to_string())?;
6425            let rows = stmt
6426                .query_map([], |row| {
6427                    Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
6428                })
6429                .map_err(|error| error.to_string())?;
6430            let mut commits = Vec::new();
6431            for row in rows {
6432                let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
6433                let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
6434                    format!("invalid persisted outbox {client_commit_id:?}: {error}")
6435                })?;
6436                let mut ops = Vec::with_capacity(entries.len());
6437                for entry in entries {
6438                    let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
6439                    ops.push(OutboxOp {
6440                        upsert: op == "upsert",
6441                        table: entry
6442                            .get("table")
6443                            .and_then(Value::as_str)
6444                            .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
6445                            .to_owned(),
6446                        row_id: entry
6447                            .get("rowId")
6448                            .and_then(Value::as_str)
6449                            .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
6450                            .to_owned(),
6451                        base_version: entry.get("baseVersion").and_then(Value::as_i64),
6452                        // A legacy entry's `changedFields` key is ignored:
6453                        // the values map is the presence set (§6.1).
6454                        values: entry.get("values").and_then(Value::as_object).cloned(),
6455                    });
6456                }
6457                commits.push(OutboxCommit {
6458                    client_commit_id,
6459                    ops,
6460                });
6461            }
6462            commits
6463        };
6464
6465        self.prune_commit_outcomes()?;
6466        let active = self.commit_outcomes(CommitOutcomeQuery {
6467            active_only: true,
6468            ..CommitOutcomeQuery::default()
6469        })?;
6470        self.conflicts = active
6471            .iter()
6472            .flat_map(|outcome| outcome.results.iter())
6473            .filter_map(|result| match result {
6474                CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
6475                _ => None,
6476            })
6477            .collect();
6478        self.rejections = active
6479            .iter()
6480            .flat_map(|outcome| outcome.results.iter())
6481            .filter_map(|result| match result {
6482                CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
6483                _ => None,
6484            })
6485            .collect();
6486
6487        self.lease_state = self
6488            .get_meta(LEASE_STATE_KEY)
6489            .map(|raw| serde_json::from_str(&raw))
6490            .transpose()
6491            .map_err(|error| format!("invalid persisted lease state: {error}"))?;
6492        self.schema_floor = self
6493            .get_meta(SCHEMA_FLOOR_KEY)
6494            .map(|raw| serde_json::from_str(&raw))
6495            .transpose()
6496            .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
6497        self.stopped = self.schema_floor.is_some();
6498        // A replica left in unactivated preflight reopens gated: the quarantine
6499        // decision persists with the data across handle teardown and restart.
6500        if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
6501            self.security_preflight = true;
6502        }
6503        Ok(())
6504    }
6505
6506    fn save_subscription_scope_schema(&self) {
6507        let declarations: BTreeMap<_, BTreeMap<_, _>> = self
6508            .schema
6509            .tables
6510            .iter()
6511            .map(|table| {
6512                (
6513                    table.name.clone(),
6514                    table
6515                        .scope_variables
6516                        .iter()
6517                        .map(|scope| {
6518                            (
6519                                scope.variable.clone(),
6520                                [scope.prefix.clone(), scope.column.clone()],
6521                            )
6522                        })
6523                        .collect(),
6524                )
6525            })
6526            .collect();
6527        self.set_meta(
6528            "subscriptionScopeSchema",
6529            &serde_json::to_string(&declarations).expect("scope schema serialization"),
6530        );
6531    }
6532
6533    /// Remove registrations with incompatible table/key/prefix/column meaning.
6534    fn prune_unknown_subscriptions(&mut self, bump: bool) -> Result<(), String> {
6535        let previous: Option<BTreeMap<String, BTreeMap<String, [String; 2]>>> = if bump {
6536            self.get_meta("subscriptionScopeSchema")
6537                .map(|raw| serde_json::from_str(&raw))
6538                .transpose()
6539                .map_err(|error| format!("invalid persisted scope schema: {error}"))?
6540        } else {
6541            None
6542        };
6543        let compatible = |table_name: &str, requested: &[(String, Vec<String>)]| {
6544            let Some(table) = self.schema.table(table_name) else {
6545                return false;
6546            };
6547            if requested
6548                .iter()
6549                .any(|(variable, _)| table.scope_column(variable).is_none())
6550            {
6551                return false;
6552            }
6553            if !bump {
6554                return true;
6555            }
6556            let Some(old) = previous.as_ref().and_then(|tables| tables.get(table_name)) else {
6557                return false;
6558            };
6559            let variables: BTreeSet<_> = if requested.is_empty() {
6560                old.keys()
6561                    .cloned()
6562                    .chain(
6563                        table
6564                            .scope_variables
6565                            .iter()
6566                            .map(|scope| scope.variable.clone()),
6567                    )
6568                    .collect()
6569            } else {
6570                requested
6571                    .iter()
6572                    .map(|(variable, _)| variable.clone())
6573                    .collect()
6574            };
6575            variables.iter().all(|variable| {
6576                let current = table
6577                    .scope_variables
6578                    .iter()
6579                    .find(|scope| &scope.variable == variable);
6580                match (old.get(variable), current) {
6581                    (Some(old), Some(current)) => {
6582                        old == &[current.prefix.clone(), current.column.clone()]
6583                    }
6584                    _ => false,
6585                }
6586            })
6587        };
6588        let mut stale_ids: BTreeSet<_> = self
6589            .subs
6590            .iter()
6591            .filter(|sub| !compatible(&sub.table, &sub.requested))
6592            .map(|sub| sub.id.clone())
6593            .collect();
6594        for (id, table, effective) in self.load_pending_evictions()? {
6595            if !compatible(&table, &effective) {
6596                stale_ids.insert(id);
6597            }
6598        }
6599        for id in &stale_ids {
6600            for sql in [
6601                "DELETE FROM _syncular_windows WHERE sub_id = ?1",
6602                "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
6603                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
6604            ] {
6605                self.conn
6606                    .execute(sql, rusqlite::params![id])
6607                    .map_err(|error| error.to_string())?;
6608            }
6609        }
6610        self.subs.retain(|sub| !stale_ids.contains(&sub.id));
6611        Ok(())
6612    }
6613
6614    #[must_use]
6615    pub fn local_revision(&self) -> u64 {
6616        self.get_meta(LOCAL_REVISION_KEY)
6617            .and_then(|value| value.parse().ok())
6618            .unwrap_or(0)
6619    }
6620
6621    #[must_use]
6622    pub fn status_snapshot(&self) -> SyncStatusSnapshot {
6623        SyncStatusSnapshot {
6624            current_schema_version: self.schema.version,
6625            outbox: self.outbox.len(),
6626            upgrading: self.upgrading,
6627            lease_state: self.lease_state.clone(),
6628            schema_floor: self.schema_floor.clone(),
6629            sync_needed: self.sync_needed,
6630            previous_version_context: self.previous_version_status(),
6631        }
6632    }
6633
6634    /// RFC 0005 A2: container presence for a host that must act on it (Diego's
6635    /// rollback precondition), read without creating the file.
6636    fn previous_version_status(&self) -> PreviousVersionStatus {
6637        let Some(replica_path) = self.previous_version_replica_path() else {
6638            return PreviousVersionStatus {
6639                present: false,
6640                created_at_ms: None,
6641            };
6642        };
6643        match crate::previous_version::read_previous_version_record(&replica_path) {
6644            Ok(Some(record)) => PreviousVersionStatus {
6645                present: true,
6646                created_at_ms: Some(record.created_at_ms),
6647            },
6648            _ => PreviousVersionStatus {
6649                present: std::path::Path::new(
6650                    &crate::previous_version::previous_version_container_path(&replica_path),
6651                )
6652                .exists(),
6653                created_at_ms: None,
6654            },
6655        }
6656    }
6657
6658    /// RFC 0005 D7: read captured rows from the previous local schema. `state`
6659    /// is always `previousVersion`; the container never makes
6660    /// `query_snapshot()` coverage complete. Lease stop/expiry, scope
6661    /// revocation, coverage completion and the TTL DISCARD the container and
6662    /// close the read with their own reason.
6663    pub fn previous_version_snapshot(
6664        &self,
6665        spec: &PreviousVersionReadSpec,
6666    ) -> Result<PreviousVersionSnapshot, String> {
6667        let now_ms = self.clock_now_ms();
6668        crate::previous_version::previous_version_snapshot(
6669            &self.conn,
6670            self.previous_version_replica_path().as_deref(),
6671            self.previous_version.as_ref(),
6672            PreviousVersionLifecycle {
6673                lease_inactive: self.previous_version_lease_inactive(now_ms),
6674                scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
6675                coverage_complete: self.previous_version_coverage_complete(),
6676            },
6677            now_ms,
6678            self.schema.version,
6679            spec,
6680        )
6681    }
6682
6683    /// RFC 0005 D6: the advisory pre-reset compatibility audit.
6684    #[must_use]
6685    pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
6686        crate::previous_version::stored_previous_version_audit(&self.conn)
6687    }
6688
6689    /// RFC 0005 A2/D9: the executable downgrade step. Drop the container file
6690    /// and both metadata records, and report whether anything was present.
6691    /// Idempotent: absence is a successful no-op.
6692    pub fn previous_version_discard(
6693        &self,
6694    ) -> crate::previous_version::PreviousVersionDiscardOutcome {
6695        self.drop_previous_version()
6696    }
6697
6698    /// The module's discard has no failure path (it removes the file even when
6699    /// the container cannot be opened), so the only error a caller could ever
6700    /// see is a bug; report absence rather than claiming a discard happened.
6701    fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
6702        let absent = crate::previous_version::PreviousVersionDiscardOutcome {
6703            present: false,
6704            discarded: false,
6705        };
6706        let Some(replica_path) = self.previous_version_replica_path() else {
6707            return absent;
6708        };
6709        crate::previous_version::discard_previous_version(&self.conn, &replica_path)
6710            .unwrap_or(absent)
6711    }
6712
6713    /// RFC 0005 D7: `leaseState.errorCode` or an expired lease.
6714    fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
6715        let Some(lease) = &self.lease_state else {
6716            return false;
6717        };
6718        if lease.error_code.is_some() {
6719            return true;
6720        }
6721        lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
6722    }
6723
6724    /// RFC 0005 D7: §7.4.5 replacement coverage — every ACTIVE subscription
6725    /// has `cursor >= 0` and no resume token. An empty active set is not
6726    /// completion (mirrors the TS predicate exactly).
6727    fn previous_version_coverage_complete(&self) -> bool {
6728        let active = self
6729            .subs
6730            .iter()
6731            .filter(|sub| sub.state == SubState::Active)
6732            .collect::<Vec<_>>();
6733        if active.is_empty() {
6734            return false;
6735        }
6736        active
6737            .iter()
6738            .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
6739    }
6740
6741    /// RFC 0005 D7: lifetime triggers evaluated once per sync round, after the
6742    /// apply. A container that no longer has a reason to exist is discarded so
6743    /// the rows do not outlive the window they were kept for.
6744    fn previous_version_lifetime_check(&self) {
6745        let Some(config) = self.previous_version.filter(|config| config.enabled) else {
6746            return;
6747        };
6748        let now_ms = self.clock_now_ms();
6749        if self.previous_version_lease_inactive(now_ms) {
6750            let _ = self.drop_previous_version();
6751            return;
6752        }
6753        let Some(replica_path) = self.previous_version_replica_path() else {
6754            return;
6755        };
6756        let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
6757        else {
6758            return;
6759        };
6760        if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
6761            let _ = self.drop_previous_version();
6762            return;
6763        }
6764        if now_ms - record.created_at_ms > config.max_age_ms {
6765            let _ = self.drop_previous_version();
6766            return;
6767        }
6768        if self.previous_version_coverage_complete() {
6769            let _ = self.drop_previous_version();
6770        }
6771    }
6772
6773    pub fn diagnostics_snapshot(
6774        &self,
6775        request: &ClientDiagnosticsRequest,
6776    ) -> Result<ClientDiagnosticsSnapshot, String> {
6777        if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
6778            return Err(format!(
6779                "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
6780            ));
6781        }
6782        let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
6783        for sub in &self.subs {
6784            let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
6785            let complete =
6786                sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
6787            let state = match sub.state {
6788                SubState::Revoked => "revoked",
6789                SubState::Failed => "failed",
6790                SubState::Active if reset => "reset",
6791                SubState::Active if complete => "complete",
6792                SubState::Active => "bootstrapping",
6793            };
6794            subscriptions.insert(
6795                sub.id.clone(),
6796                DiagnosticSubscription {
6797                    id: sub.id.clone(),
6798                    table: sub.table.clone(),
6799                    state: state.to_owned(),
6800                    complete,
6801                    cursor: Some(sub.cursor),
6802                    reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
6803                },
6804            );
6805        }
6806        for expected in &request.expected_subscriptions {
6807            if expected.id.is_empty() || expected.table.is_empty() {
6808                return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
6809            }
6810            if subscriptions
6811                .get(&expected.id)
6812                .is_some_and(|registered| registered.table != expected.table)
6813            {
6814                subscriptions.insert(
6815                    expected.id.clone(),
6816                    DiagnosticSubscription {
6817                        id: expected.id.clone(),
6818                        table: expected.table.clone(),
6819                        state: "failed".to_owned(),
6820                        complete: false,
6821                        cursor: None,
6822                        reason_code: Some("client.subscription_intent_mismatch".to_owned()),
6823                    },
6824                );
6825            } else {
6826                subscriptions.entry(expected.id.clone()).or_insert_with(|| {
6827                    DiagnosticSubscription {
6828                        id: expected.id.clone(),
6829                        table: expected.table.clone(),
6830                        state: "unregistered".to_owned(),
6831                        complete: false,
6832                        cursor: None,
6833                        reason_code: None,
6834                    }
6835                });
6836            }
6837        }
6838        let mut ordered_subscriptions = Vec::new();
6839        let mut included = BTreeSet::new();
6840        for expected in &request.expected_subscriptions {
6841            if included.insert(expected.id.clone()) {
6842                if let Some(subscription) = subscriptions.get(&expected.id) {
6843                    ordered_subscriptions.push(subscription.clone());
6844                }
6845            }
6846        }
6847        for (id, subscription) in &subscriptions {
6848            if included.insert(id.clone()) {
6849                ordered_subscriptions.push(subscription.clone());
6850            }
6851        }
6852        let subscriptions_truncated =
6853            ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
6854        ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
6855        let captured_at_ms = self.clock_now_ms();
6856        let lease = if let Some(error_code) = self
6857            .lease_state
6858            .as_ref()
6859            .and_then(|state| state.error_code.clone())
6860        {
6861            ClientDiagnosticsLease {
6862                state: "stopped".to_owned(),
6863                expires_at_ms: self
6864                    .lease_state
6865                    .as_ref()
6866                    .and_then(|state| state.expires_at_ms),
6867                error_code: Some(Self::diagnostic_code(&error_code)),
6868            }
6869        } else if let Some(expires_at_ms) = self
6870            .lease_state
6871            .as_ref()
6872            .and_then(|state| state.expires_at_ms)
6873        {
6874            ClientDiagnosticsLease {
6875                state: if expires_at_ms <= captured_at_ms {
6876                    "expired".to_owned()
6877                } else {
6878                    "active".to_owned()
6879                },
6880                expires_at_ms: Some(expires_at_ms),
6881                error_code: None,
6882            }
6883        } else {
6884            ClientDiagnosticsLease {
6885                state: "none".to_owned(),
6886                expires_at_ms: None,
6887                error_code: None,
6888            }
6889        };
6890        let connectivity = match self.last_round.as_ref() {
6891            Some(round) if round.status == "succeeded" => "online",
6892            Some(round)
6893                if round.status == "failed"
6894                    && round
6895                        .error_code
6896                        .as_deref()
6897                        .is_some_and(Self::retryable_transport_code) =>
6898            {
6899                "offline"
6900            }
6901            _ => "unknown",
6902        };
6903        Ok(ClientDiagnosticsSnapshot {
6904            version: CLIENT_DIAGNOSTICS_VERSION,
6905            captured_at_ms,
6906            host: ClientDiagnosticsHost {
6907                kind: "direct".to_owned(),
6908                role: "single".to_owned(),
6909                connectivity: connectivity.to_owned(),
6910                realtime: self.realtime_state,
6911                realtime_policy: self.realtime_policy,
6912                realtime_reason_code: self.realtime_reason_code.clone(),
6913                realtime_retry_delay_ms: self.realtime_retry_delay_ms,
6914            },
6915            security_lifecycle: self.security_lifecycle().to_owned(),
6916            schema: ClientDiagnosticsSchema {
6917                current_version: self.schema.version,
6918                upgrading: self.upgrading,
6919                required_version: self
6920                    .schema_floor
6921                    .as_ref()
6922                    .and_then(|floor| floor.required_schema_version),
6923                latest_version: self
6924                    .schema_floor
6925                    .as_ref()
6926                    .and_then(|floor| floor.latest_schema_version),
6927            },
6928            replica: ClientDiagnosticsReplica {
6929                local_revision: self.local_revision().to_string(),
6930                sync_needed: self.sync_needed,
6931                pending_outbox: self.outbox.len(),
6932            },
6933            lease,
6934            subscriptions: ordered_subscriptions,
6935            subscriptions_truncated,
6936            last_round: self.last_round.clone(),
6937            last_change: self.last_change.clone(),
6938            storage: self.diagnostics_storage(),
6939            query_failures: self.query_failures.clone(),
6940        })
6941    }
6942
6943    fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
6944        let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
6945            let page_count: i64 = self
6946                .conn
6947                .query_row("PRAGMA page_count", [], |row| row.get(0))?;
6948            let page_size: i64 = self
6949                .conn
6950                .query_row("PRAGMA page_size", [], |row| row.get(0))?;
6951            // Reuse compiled aggregates; every invocation still reads current storage.
6952            let outbox_bytes: i64 = self
6953                .conn
6954                .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
6955                .query_row([], |row| row.get(0))?;
6956            let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
6957                "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
6958            )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
6959            let blob_bytes = if self.schema_has_blobs() {
6960                self.conn
6961                    .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
6962                    .query_row([], |row| row.get(0))?
6963            } else {
6964                0
6965            };
6966            let pressure = self
6967                .limits
6968                .blob_cache_max_bytes
6969                .is_some_and(|limit| blob_bytes > limit);
6970            Ok(ClientDiagnosticsStorage {
6971                status: if pressure { "pressure" } else { "healthy" }.to_owned(),
6972                database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
6973                pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
6974                retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
6975                retained_outcome_entries: Some(outcome_entries.max(0)),
6976                blob_cache_bytes_approx: Some(blob_bytes.max(0)),
6977                pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
6978            })
6979        };
6980        read().unwrap_or_else(|_| ClientDiagnosticsStorage {
6981            status: "unreadable".to_owned(),
6982            database_bytes_approx: None,
6983            pending_outbox_bytes_approx: None,
6984            retained_outcome_bytes_approx: None,
6985            retained_outcome_entries: None,
6986            blob_cache_bytes_approx: None,
6987            pressure_reason_code: None,
6988        })
6989    }
6990
6991    pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
6992        self.change_queue.drain(..).collect()
6993    }
6994
6995    pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
6996        self.sync_intent_queue.drain(..).collect()
6997    }
6998
6999    fn schedule_background_retry(&mut self) -> u64 {
7000        let delay_ms = self.retry_delay_ms;
7001        self.sync_intent_queue
7002            .push_back(SyncIntent::Background { delay_ms });
7003        self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
7004        self.round_retry_delay_ms = Some(delay_ms);
7005        delay_ms
7006    }
7007
7008    fn reset_background_retry(&mut self) {
7009        self.retry_delay_ms = 250;
7010    }
7011
7012    fn retryable_transport_code(code: &str) -> bool {
7013        code == "transport.failed"
7014            || code == "transport.unavailable"
7015            || code == "sync.transport_failed"
7016    }
7017
7018    /// A failed round worth a background retry: a transport failure, or a
7019    /// §10.2 code the catalog marks retryable (clients may hardcode the
7020    /// catalog metadata, §10.1). Matches the TypeScript core, which retries
7021    /// on the server's `retryable` flag.
7022    fn retryable_failure_code(code: &str) -> bool {
7023        Self::retryable_transport_code(code)
7024            || matches!(
7025                code,
7026                "sync.auth_required"
7027                    | "sync.auth_lease_required"
7028                    | "sync.auth_lease_revoked"
7029                    | "sync.internal_error"
7030                    | "sync.idempotency_cache_miss"
7031                    | "sync.schema_not_ready"
7032                    | "sync.segment_expired"
7033                    | "sync.rate_limited"
7034                    | "sync.websocket_connection_limit"
7035            )
7036    }
7037
7038    fn diagnostic_code(code: &str) -> String {
7039        let valid = !code.is_empty()
7040            && code.len() <= 96
7041            && code.contains('.')
7042            && code
7043                .bytes()
7044                .next()
7045                .is_some_and(|byte| byte.is_ascii_lowercase())
7046            && code.bytes().all(|byte| {
7047                byte.is_ascii_lowercase()
7048                    || byte.is_ascii_digit()
7049                    || matches!(byte, b'.' | b'_' | b'-')
7050            });
7051        if valid {
7052            code.to_owned()
7053        } else {
7054            "client.unknown_failure".to_owned()
7055        }
7056    }
7057
7058    fn set_sync_needed(&mut self, value: bool, interactive: bool) {
7059        if self.sync_needed != value {
7060            if self.begin_observation("syncular_status").is_ok() {
7061                self.sync_needed = value;
7062                let batch = ChangeAccumulator {
7063                    status: true,
7064                    ..ChangeAccumulator::default()
7065                };
7066                if self.finish_observation("syncular_status", batch).is_err() {
7067                    self.rollback_observation("syncular_status");
7068                }
7069            } else {
7070                self.sync_needed = value;
7071            }
7072        }
7073        if value && interactive {
7074            self.sync_intent_queue.push_back(SyncIntent::Interactive);
7075        }
7076    }
7077
7078    fn sqlite_failure(
7079        storage_failure: &RefCell<Option<QueryReadFailure>>,
7080        error: rusqlite::Error,
7081    ) -> String {
7082        let message = error.to_string();
7083        let failure = QueryReadFailure::from(error);
7084        let code = failure.code;
7085        if code.is_some() && storage_failure.borrow().is_none() {
7086            *storage_failure.borrow_mut() = Some(failure);
7087        }
7088        match code {
7089            Some(code) => format!("{code}: {message}"),
7090            None => message,
7091        }
7092    }
7093
7094    fn begin_observation(&self, name: &str) -> Result<(), String> {
7095        self.conn
7096            .execute_batch(&format!("SAVEPOINT {name}"))
7097            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7098    }
7099
7100    fn rollback_observation(&self, name: &str) {
7101        if let Err(error) = self
7102            .conn
7103            .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"))
7104        {
7105            if let Some(failure) = self.storage_failure.borrow_mut().as_mut() {
7106                if failure.rollback_failure.is_none() {
7107                    failure.rollback_failure = Some(Box::new(QueryReadFailure::from(error)));
7108                }
7109            }
7110        }
7111    }
7112
7113    fn finish_observation(
7114        &mut self,
7115        name: &str,
7116        mut batch: ChangeAccumulator,
7117    ) -> Result<(), String> {
7118        #[cfg(feature = "bench-internals")]
7119        let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
7120        if self.failed_commits.iter().any(|(commit, _)| {
7121            commit
7122                .ops
7123                .iter()
7124                .any(|operation| batch.tables.contains_key(&operation.table))
7125        }) {
7126            batch.outcomes = true;
7127        }
7128        if !batch.touched() {
7129            self.conn
7130                .execute_batch(&format!("RELEASE {name}"))
7131                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7132            return Ok(());
7133        }
7134        let revision = self
7135            .local_revision()
7136            .checked_add(1)
7137            .ok_or_else(|| "local revision exhausted u64".to_owned())?;
7138        self.conn
7139            .execute(
7140                "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
7141                rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
7142            )
7143            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7144        let status = batch.status.then(|| self.status_snapshot());
7145        let event = ClientChangeBatch {
7146            revision: revision.to_string(),
7147            tables: batch
7148                .tables
7149                .into_iter()
7150                .map(|(table, scope_keys)| TableChange {
7151                    table,
7152                    scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
7153                })
7154                .collect(),
7155            windows: batch
7156                .windows
7157                .into_iter()
7158                .map(|((base_key, table), units)| WindowChange {
7159                    base_key,
7160                    table,
7161                    units: units.into_iter().collect(),
7162                })
7163                .collect(),
7164            status,
7165            conflicts_changed: batch.conflicts,
7166            rejections_changed: batch.rejections,
7167            outcomes_changed: batch.outcomes,
7168        };
7169        self.conn
7170            .execute_batch(&format!("RELEASE {name}"))
7171            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7172        let mut diagnostic_tables = event
7173            .tables
7174            .iter()
7175            .map(|entry| entry.table.clone())
7176            .collect::<BTreeSet<_>>()
7177            .into_iter()
7178            .collect::<Vec<_>>();
7179        let mut diagnostic_windows = event
7180            .windows
7181            .iter()
7182            .map(|entry| entry.table.clone())
7183            .collect::<BTreeSet<_>>()
7184            .into_iter()
7185            .collect::<Vec<_>>();
7186        let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
7187            || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
7188        diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
7189        diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
7190        self.last_change = Some(DiagnosticLastChange {
7191            revision: event.revision.clone(),
7192            recorded_at_ms: self.clock_now_ms(),
7193            tables: diagnostic_tables,
7194            windows: diagnostic_windows,
7195            domains_truncated,
7196            status_changed: event.status.is_some(),
7197            conflicts_changed: event.conflicts_changed,
7198            rejections_changed: event.rejections_changed,
7199            outcomes_changed: event.outcomes_changed,
7200        });
7201        self.change_queue.push_back(event);
7202        Ok(())
7203    }
7204
7205    fn record_scope_map(
7206        &self,
7207        batch: &mut ChangeAccumulator,
7208        table_name: &str,
7209        scopes: &[(String, Vec<String>)],
7210    ) {
7211        let Some(table) = self.schema.table(table_name) else {
7212            return;
7213        };
7214        for (variable, values) in scopes {
7215            let Some(scope) = table
7216                .scope_variables
7217                .iter()
7218                .find(|scope| &scope.variable == variable)
7219            else {
7220                continue;
7221            };
7222            for value in values {
7223                batch.scope(table_name, format!("{}:{value}", scope.prefix));
7224            }
7225        }
7226    }
7227
7228    /// Record a row's current scope keys from the base or visible table.
7229    fn record_row_scopes(
7230        &self,
7231        batch: &mut ChangeAccumulator,
7232        table_name: &str,
7233        row_id: &str,
7234        base: bool,
7235    ) -> bool {
7236        let Some(table) = self.schema.table(table_name) else {
7237            return false;
7238        };
7239        if table.scope_variables.is_empty() {
7240            return false;
7241        }
7242        let columns = table
7243            .scope_variables
7244            .iter()
7245            .map(|scope| quote_ident(&scope.column))
7246            .collect::<Vec<_>>()
7247            .join(", ");
7248        let full_table = if base {
7249            base_table(table_name)
7250        } else {
7251            visible_table(table_name)
7252        };
7253        let sql = format!(
7254            "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
7255            row_id_predicate(table)
7256        );
7257        let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
7258            return false;
7259        };
7260        let values = stmt.query_row(rusqlite::params![row_id], |row| {
7261            let mut values = Vec::with_capacity(table.scope_variables.len());
7262            for index in 0..table.scope_variables.len() {
7263                values.push(row.get::<_, Option<String>>(index)?);
7264            }
7265            Ok(values)
7266        });
7267        let Ok(values) = values else {
7268            return false;
7269        };
7270        let mut recorded = false;
7271        for (scope, value) in table.scope_variables.iter().zip(values) {
7272            if let Some(value) = value {
7273                batch.scope(table_name, format!("{}:{value}", scope.prefix));
7274                recorded = true;
7275            }
7276        }
7277        recorded
7278    }
7279
7280    fn record_commit_changes(
7281        &self,
7282        batch: &mut ChangeAccumulator,
7283        tables: &[String],
7284        changes: &[ssp2::model::Change],
7285    ) {
7286        #[cfg(feature = "bench-internals")]
7287        let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
7288        for change in changes {
7289            let Some(table_name) = tables.get(change.table_index as usize) else {
7290                continue;
7291            };
7292            let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
7293            if let Some(table) = self.schema.table(table_name) {
7294                for (variable, value) in &change.scopes {
7295                    if let Some(scope) = table
7296                        .scope_variables
7297                        .iter()
7298                        .find(|scope| &scope.variable == variable)
7299                    {
7300                        batch.scope(table_name, format!("{}:{value}", scope.prefix));
7301                        precise = true;
7302                    }
7303                }
7304            }
7305            if !precise {
7306                batch.table(table_name);
7307            }
7308        }
7309    }
7310
7311    fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
7312        if effective.is_empty() {
7313            return false;
7314        }
7315        let Some(table) = self.schema.table(table_name) else {
7316            return false;
7317        };
7318        let mut clauses = Vec::new();
7319        let mut params = Vec::new();
7320        for (variable, values) in effective {
7321            let Some(column) = table.scope_column(variable) else {
7322                return false;
7323            };
7324            if values.is_empty() {
7325                return false;
7326            }
7327            let placeholders = values
7328                .iter()
7329                .map(|value| {
7330                    params.push(SqlValue::Text(value.clone()));
7331                    "?"
7332                })
7333                .collect::<Vec<_>>()
7334                .join(", ");
7335            clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
7336        }
7337        let sql = format!(
7338            "SELECT 1 FROM {} WHERE {} LIMIT 1",
7339            base_table(table_name),
7340            clauses.join(" AND ")
7341        );
7342        self.conn
7343            .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
7344            .is_ok()
7345    }
7346
7347    /// §7.4.5: true while a schema-bump reset + first re-bootstrap runs.
7348    pub fn upgrading(&self) -> bool {
7349        self.upgrading
7350    }
7351
7352    /// §7.4.2 "app ships new code": swap to a NEW generated schema while
7353    /// keeping this client's local database (identity, outbox, tables). The
7354    /// §7.4.1 marker check then fires the wipe/re-bootstrap flow when the
7355    /// version changed. Mirrors the TS client's boot-time detection —
7356    /// the Rust core has no persistent restart, so recreation IS the boot.
7357    pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
7358        let new_schema = parse_schema_json(schema_json)?;
7359        let marker: Option<i32> = self
7360            .get_meta(LOCAL_SCHEMA_VERSION_KEY)
7361            .and_then(|v| v.parse().ok());
7362        self.schema = new_schema;
7363        self.create_bookkeeping_tables()?;
7364        if marker != Some(self.schema.version) {
7365            self.run_schema_reset()?;
7366        }
7367        self.prune_unknown_subscriptions(false)?;
7368        self.save_subscription_scope_schema();
7369        // The conformance recreate is the in-memory equivalent of reopening a
7370        // durable client. Apply the same startup catch-up contract even when
7371        // the schema itself did not change.
7372        self.enqueue_startup_sync_if_needed()?;
7373        Ok(())
7374    }
7375
7376    fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
7377        let startup_work = !self.stopped
7378            && (!self.outbox.is_empty()
7379                || self.has_acknowledged_rows()
7380                || !self.load_pending_evictions()?.is_empty()
7381                || self.subs.iter().any(|sub| sub.state == SubState::Active));
7382        if startup_work {
7383            self.sync_needed = true;
7384            self.sync_intent_queue.push_back(SyncIntent::Interactive);
7385        }
7386        Ok(())
7387    }
7388
7389    /// A native client persists schema-floor stops across process restarts.
7390    /// Once the running app already satisfies that floor, the persisted stop
7391    /// is only stale evidence from an older server round (for example, the
7392    /// server was restarted after catching up to an app that was ahead).
7393    /// Clear it and let the normal startup pull re-negotiate. If the server is
7394    /// still incompatible it will return the floor again in that first round.
7395    fn clear_satisfied_persisted_schema_floor(&mut self) {
7396        let satisfied = self
7397            .schema_floor
7398            .as_ref()
7399            .and_then(|floor| floor.required_schema_version)
7400            .is_some_and(|required| self.schema.version >= required);
7401        if !satisfied {
7402            return;
7403        }
7404        self.schema_floor = None;
7405        self.stopped = false;
7406        self.delete_meta(SCHEMA_FLOOR_KEY);
7407    }
7408
7409    /// RFC 0005 D1: discard an orphan or stale container at boot. The reset
7410    /// path already swept it; this covers a same-version or fresh-install open,
7411    /// which runs no reset and therefore no sweep — plus the D7 TTL, which only
7412    /// an aware binary can apply.
7413    fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
7414        let Some(replica_path) = self.previous_version_replica_path() else {
7415            return Ok(());
7416        };
7417        let max_age_ms = self
7418            .previous_version
7419            .filter(|config| config.enabled)
7420            .map(|config| config.max_age_ms);
7421        reconcile_previous_version_at_boot(
7422            &self.conn,
7423            &replica_path,
7424            self.schema.version,
7425            max_age_ms,
7426            self.clock_now_ms(),
7427        )
7428    }
7429
7430    /// RFC 0005 D3: the sibling container path, or `None` when the replica has
7431    /// no file — an in-memory replica returns an empty path, and a `file:` URI
7432    /// has no plain sibling path — so the feature resolves as off there.
7433    fn previous_version_replica_path(&self) -> Option<String> {
7434        let path = self.conn.path()?;
7435        if path.is_empty() || path.starts_with("file:") {
7436            return None;
7437        }
7438        Some(path.to_owned())
7439    }
7440
7441    /// §7.4.3 reset: whole-database local reset EXCEPT the outbox, clientId,
7442    /// and leaseState. Drops/recreates every synced table from the new
7443    /// schema, resets subscription sync-state (keeping registrations), clears
7444    /// the schema-floor stop state, rewrites the marker, drops outbox commits
7445    /// that cannot re-encode (§7.4.4), and replays the survivors on top.
7446    fn run_schema_reset(&mut self) -> Result<(), String> {
7447        self.begin_observation("syncular_schema_reset")?;
7448        let prior_subs = self.subs.clone();
7449        let mut batch = ChangeAccumulator::default();
7450        let result = self.run_schema_reset_observed(&mut batch, true, true);
7451        if let Err(error) = result {
7452            self.rollback_observation("syncular_schema_reset");
7453            self.subs = prior_subs;
7454            return Err(error);
7455        }
7456        if let Err(error) = self.finish_observation("syncular_schema_reset", batch) {
7457            self.rollback_observation("syncular_schema_reset");
7458            self.subs = prior_subs;
7459            return Err(error);
7460        }
7461        Ok(())
7462    }
7463
7464    fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
7465        let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
7466        self.begin_observation("syncular_log_epoch_reset")?;
7467        let prior_subs = self.subs.clone();
7468        let mut batch = ChangeAccumulator::default();
7469        let result = self.run_schema_reset_observed(&mut batch, false, false);
7470        if let Err(error) = result {
7471            self.rollback_observation("syncular_log_epoch_reset");
7472            self.subs = prior_subs;
7473            return Err(error);
7474        }
7475        self.set_meta(LOG_EPOCH_KEY, log_epoch);
7476        self.sync_needed = true;
7477        batch.status = true;
7478        if let Err(error) = self.finish_observation("syncular_log_epoch_reset", batch) {
7479            self.rollback_observation("syncular_log_epoch_reset");
7480            self.subs = prior_subs;
7481            return Err(error);
7482        }
7483        self.sync_intent_queue.push_back(SyncIntent::Interactive);
7484        Ok(resets)
7485    }
7486
7487    fn run_schema_reset_observed(
7488        &mut self,
7489        batch: &mut ChangeAccumulator,
7490        drop_incompatible: bool,
7491        capture: bool,
7492    ) -> Result<(), String> {
7493        self.conn
7494            .execute_batch(
7495                "DELETE FROM _syncular_acked_rows; DELETE FROM _syncular_row_deliveries;",
7496            )
7497            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7498        self.cancel_sync_round();
7499        self.upgrading = true;
7500        batch.status = true;
7501        // RFC 0005 D6: classify the pending outbox against the NEW compiled
7502        // schema BEFORE the wipe, and record the advisory audit. It drops
7503        // nothing — the §7.4.4 send-time drop stays the only path that removes
7504        // a commit. The classification is shared with that drop so the two can
7505        // never disagree about what is incompatible.
7506        if self.previous_version.is_some_and(|config| config.enabled) {
7507            let previous_version = self
7508                .get_meta(LOCAL_SCHEMA_VERSION_KEY)
7509                .and_then(|value| value.parse::<i32>().ok())
7510                .unwrap_or(self.schema.version);
7511            let pending = self
7512                .outbox
7513                .iter()
7514                .map(|commit| PendingCommitAudit {
7515                    commit_id: &commit.client_commit_id,
7516                    operations: commit_audit_operations(commit),
7517                })
7518                .collect::<Vec<_>>();
7519            crate::previous_version::write_previous_version_audit(
7520                &self.conn,
7521                &crate::previous_version::build_previous_version_audit(
7522                    &self.schema,
7523                    &pending,
7524                    previous_version,
7525                    self.schema.version,
7526                    self.clock_now_ms(),
7527                ),
7528            );
7529        }
7530        // RFC 0005 D5: the container lives in its own FILE, so the reset can
7531        // never reach it. Sweep first (unconditional — also on the log-epoch
7532        // reset, which captures nothing), then capture BEFORE the wipe drops
7533        // any row. A refused capture records its reason and leaves no file.
7534        if let Some(replica_path) = self.previous_version_replica_path() {
7535            sweep_previous_version_container(&self.conn, &replica_path)?;
7536            if capture {
7537                if let Some(config) = self.previous_version.filter(|config| config.enabled) {
7538                    let previous_version = self
7539                        .get_meta(LOCAL_SCHEMA_VERSION_KEY)
7540                        .and_then(|value| value.parse::<i32>().ok())
7541                        .unwrap_or(self.schema.version);
7542                    capture_previous_version_from_replica(
7543                        &self.conn,
7544                        &replica_path,
7545                        previous_version,
7546                        self.schema.version,
7547                        &config,
7548                        self.clock_now_ms(),
7549                    )?;
7550                }
7551            }
7552        }
7553        for table in &self.schema.tables {
7554            batch.table(&table.name);
7555        }
7556        for (base_key, unit, table) in self.load_registered_window_units() {
7557            batch.window(&base_key, &table, &unit);
7558        }
7559        if capture {
7560            self.prune_unknown_subscriptions(true)?;
7561        }
7562        // The per-table insert SQL is derived from the OLD column lists.
7563        self.insert_sql.borrow_mut().clear();
7564        self.overlay_dirty.set(true);
7565        // Drop every synced table (base + visible) that currently exists —
7566        // discovered from sqlite_master so a bump that adds/removes tables is
7567        // handled. Bookkeeping tables (`_syncular_outbox/_subscriptions/_meta`
7568        // and the blob cache) are preserved; base tables are `_syncular_base_*`
7569        // so they are matched explicitly, not by the bookkeeping filter.
7570        // Drop virtual tables first so SQLite removes every FTS shadow table
7571        // atomically instead of the generic discovery tearing it apart.
7572        let virtual_tables: Vec<String> = {
7573            let mut stmt = self
7574                .conn
7575                .prepare(
7576                    "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
7577                )
7578                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7579            let rows = stmt
7580                .query_map([], |row| row.get::<_, String>(0))
7581                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7582            rows.filter_map(Result::ok)
7583                .filter(|name| is_synced_table_name(name))
7584                .collect()
7585        };
7586        for name in virtual_tables {
7587            self.conn
7588                .execute_batch(&format!(
7589                    "DROP TABLE IF EXISTS {}",
7590                    quote_ident(&format!("_syncular_fts_{name}"))
7591                ))
7592                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7593            self.conn
7594                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
7595                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7596        }
7597        let existing: Vec<String> = {
7598            let mut stmt = self
7599                .conn
7600                .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
7601                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7602            let rows = stmt
7603                .query_map([], |row| row.get::<_, String>(0))
7604                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7605            rows.filter_map(Result::ok)
7606                .filter(|name| is_synced_table_name(name))
7607                .collect()
7608        };
7609        for name in &existing {
7610            if let Some(table) = name.strip_prefix("_syncular_base_") {
7611                batch.table(table);
7612            } else if !name.starts_with("_syncular_") {
7613                batch.table(name);
7614            }
7615        }
7616        for name in existing {
7617            self.conn
7618                .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
7619                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7620        }
7621        // Recreate the synced tables from the NEW schema.
7622        self.create_synced_tables()?;
7623        // Reset every subscription's sync-state, keeping the registration.
7624        for sub in &mut self.subs {
7625            sub.cursor = -1;
7626            sub.bootstrap_state = None;
7627            sub.effective = None;
7628            sub.state = SubState::Active;
7629            sub.reason_code = None;
7630            sub.synced_once = false;
7631        }
7632        let subs = self.subs.clone();
7633        for sub in &subs {
7634            self.persist_sub(sub)?;
7635        }
7636        // The stop state is over: this client now ships a servable schema.
7637        self.stopped = false;
7638        self.schema_floor = None;
7639        self.delete_meta(SCHEMA_FLOOR_KEY);
7640        // Rewrite the marker LAST so a crash mid-reset re-runs the reset.
7641        self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
7642        // D1: the descriptor follows every marker write. A crash in between
7643        // self-heals on the next same-version open, which backfills it.
7644        set_local_schema_descriptor(&self.conn, &self.schema);
7645        self.save_subscription_scope_schema();
7646        // §7.4.4: drop outbox commits that cannot re-encode under the new
7647        // schema (a referenced column/table the bump removed), surfacing each
7648        // as a `sync.outbox_incompatible` rejection.
7649        if drop_incompatible && self.drop_incompatible_outbox()? {
7650            batch.rejections = true;
7651            batch.status = true;
7652            batch.outcomes = true;
7653        }
7654        // Re-apply the surviving outbox optimistically over the empty tables.
7655        self.rebuild_overlay();
7656        Ok(())
7657    }
7658
7659    /// §7.4.4: a persisted upsert whose values reference a column the current
7660    /// schema lacks (or a removed table) cannot be encoded. Drop the commit
7661    /// and raise a client-local `sync.outbox_incompatible` rejection.
7662    fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
7663        let incompatible = self
7664            .outbox
7665            .iter()
7666            .filter(|commit| {
7667                first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
7668            })
7669            .cloned()
7670            .collect::<Vec<_>>();
7671        if incompatible.is_empty() {
7672            return Ok(false);
7673        }
7674        let mut rejections = Vec::new();
7675        for commit in &incompatible {
7676            let results = commit
7677                .ops
7678                .iter()
7679                .enumerate()
7680                .map(|(op_index, operation)| {
7681                    let rejection = RejectionRecord {
7682                        client_commit_id: commit.client_commit_id.clone(),
7683                        op_index: op_index as i32,
7684                        code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
7685                        message: "the persisted commit cannot encode under the current schema"
7686                            .to_owned(),
7687                        retryable: false,
7688                        details: None,
7689                        operation: Some(CommitOperation::from(operation)),
7690                    };
7691                    rejections.push(rejection.clone());
7692                    CommitOperationOutcome::Error { rejection }
7693                })
7694                .collect::<Vec<_>>();
7695            self.persist_commit_outcome(
7696                &commit.client_commit_id,
7697                CommitOutcomeStatus::Rejected,
7698                &results,
7699                Some(&commit.ops),
7700            )?;
7701            self.delete_outbox_persisted(&commit.client_commit_id)?;
7702        }
7703        self.prune_commit_outcomes()?;
7704        let incompatible_ids = incompatible
7705            .iter()
7706            .map(|commit| commit.client_commit_id.as_str())
7707            .collect::<BTreeSet<_>>();
7708        self.outbox
7709            .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
7710        self.rejections.extend(rejections);
7711        Ok(true)
7712    }
7713
7714    /// §7.4.3: (re)create the base + visible table pair for every synced
7715    /// table in the CURRENT schema (idempotent — `IF NOT EXISTS`).
7716    fn create_synced_tables(&self) -> Result<(), String> {
7717        for table in &self.schema.tables {
7718            // The base half + the visible half form the synced-table pair. An
7719            // index name is global in SQLite, so the base half's indexes are
7720            // name-prefixed (`_syncular_base_<index>`) to stay distinct.
7721            for (full, index_prefix) in [
7722                (base_table(&table.name), "_syncular_base_"),
7723                (visible_table(&table.name), ""),
7724            ] {
7725                let mut cols: Vec<String> =
7726                    table.columns.iter().map(|c| quote_ident(&c.name)).collect();
7727                cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
7728                let sql = format!(
7729                    "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
7730                    cols.join(", "),
7731                    quote_ident(&table.primary_key)
7732                );
7733                self.conn
7734                    .execute(&sql, [])
7735                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7736                // Local secondary indexes (CREATE INDEX subset). Created on
7737                // both halves so mirror reads hit an index on either. Runs on
7738                // both the initial create and the §7.4.3 reset recreate path.
7739                for index in &table.indexes {
7740                    let unique = if index.unique { "UNIQUE " } else { "" };
7741                    let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
7742                    let cols_sql = index
7743                        .columns
7744                        .iter()
7745                        .map(|c| quote_ident(c))
7746                        .collect::<Vec<_>>()
7747                        .join(", ");
7748                    let index_sql = format!(
7749                        "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
7750                    );
7751                    self.conn
7752                        .execute(&index_sql, [])
7753                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7754                }
7755            }
7756        }
7757        // FTS exists only on the visible half. It is a local search
7758        // projection, never a synced/base table or a wire-schema member.
7759        for table in &self.schema.tables {
7760            for index in &table.fts_indexes {
7761                self.create_fts_projection(table, index)?;
7762            }
7763        }
7764        Ok(())
7765    }
7766
7767    fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
7768        let count: i64 = self
7769            .conn
7770            .query_row(
7771                "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
7772                rusqlite::params![index.name],
7773                |row| row.get(0),
7774            )
7775            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7776        Ok(count > 0)
7777    }
7778
7779    fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
7780        for suffix in ["ai", "ad", "au"] {
7781            self.conn
7782                .execute(
7783                    &format!(
7784                        "DROP TRIGGER IF EXISTS {}",
7785                        quote_ident(&format!("{}_{suffix}", index.name))
7786                    ),
7787                    [],
7788                )
7789                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7790        }
7791        Ok(())
7792    }
7793
7794    fn create_fts_triggers(
7795        &self,
7796        table: &TableSchema,
7797        index: &FtsIndexSchema,
7798    ) -> Result<(), String> {
7799        let fts = quote_ident(&index.name);
7800        let source = visible_table(&table.name);
7801        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
7802        let pk = quote_ident(&table.primary_key);
7803        let projection_columns = std::iter::once(source_id.clone())
7804            .chain(index.columns.iter().map(|column| quote_ident(column)))
7805            .collect::<Vec<_>>()
7806            .join(", ");
7807        let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
7808            .chain(
7809                index
7810                    .columns
7811                    .iter()
7812                    .map(|column| format!("new.{}", quote_ident(column))),
7813            )
7814            .collect::<Vec<_>>()
7815            .join(", ");
7816        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7817        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)");
7818        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)");
7819        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})");
7820        let bi = quote_ident(&format!("{}_bi", index.name));
7821        let ai = quote_ident(&format!("{}_ai", index.name));
7822        let ad = quote_ident(&format!("{}_ad", index.name));
7823        let au = quote_ident(&format!("{}_au", index.name));
7824        let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
7825        let sql = format!(
7826            "DROP TRIGGER IF EXISTS {bi};
7827             DROP TRIGGER IF EXISTS {ai};
7828             DROP TRIGGER IF EXISTS {ad};
7829             DROP TRIGGER IF EXISTS {au};
7830             CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
7831             CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
7832             CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
7833             CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
7834        );
7835        self.conn
7836            .execute_batch(&sql)
7837            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7838    }
7839
7840    fn rebuild_fts_projection(
7841        &self,
7842        table: &TableSchema,
7843        index: &FtsIndexSchema,
7844    ) -> Result<(), String> {
7845        let fts = quote_ident(&index.name);
7846        let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
7847        let pk = quote_ident(&table.primary_key);
7848        let indexed_columns = index
7849            .columns
7850            .iter()
7851            .map(|column| quote_ident(column))
7852            .collect::<Vec<_>>();
7853        let projection_columns = std::iter::once(source_id.clone())
7854            .chain(indexed_columns.iter().cloned())
7855            .collect::<Vec<_>>()
7856            .join(", ");
7857        let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7858        let sql = format!(
7859            "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};",
7860            columns = indexed_columns.join(", "),
7861            source = visible_table(&table.name),
7862        );
7863        self.conn
7864            .execute_batch(&sql)
7865            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7866    }
7867
7868    fn create_fts_projection(
7869        &self,
7870        table: &TableSchema,
7871        index: &FtsIndexSchema,
7872    ) -> Result<(), String> {
7873        self.begin_observation("syncular_fts_schema")?;
7874        let result = (|| {
7875            let existed = self.fts_projection_exists(index)?;
7876            let tokenizer = index.tokenize.replace('\'', "''");
7877            let columns = index
7878                .columns
7879                .iter()
7880                .map(|column| quote_ident(column))
7881                .collect::<Vec<_>>()
7882                .join(", ");
7883            let sql = format!(
7884            "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
7885            fts = quote_ident(&index.name),
7886            source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
7887        );
7888            self.conn.execute(&sql, []).map_err(|error| {
7889                format!(
7890                    "cannot create local FTS5 projection {:?}: {error}",
7891                    index.name
7892                )
7893            })?;
7894            let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
7895            let mapped: bool = self
7896                .conn
7897                .query_row(
7898                    "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
7899                    [format!("_syncular_fts_{}", index.name)],
7900                    |row| row.get(0),
7901                )
7902                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7903            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))?;
7904            if !mapped && existed {
7905                self.conn
7906                    .execute_batch(&format!(
7907                        "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
7908                        quote_ident(FTS_SOURCE_ID_COLUMN),
7909                        quote_ident(&index.name)
7910                    ))
7911                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7912            }
7913            self.create_fts_triggers(table, index)?;
7914            if !existed {
7915                self.rebuild_fts_projection(table, index)?;
7916            }
7917            Ok(())
7918        })();
7919        if let Err(error) = result {
7920            self.rollback_observation("syncular_fts_schema");
7921            return Err(error);
7922        }
7923        if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
7924            self.rollback_observation("syncular_fts_schema");
7925            return Err(error.to_string());
7926        }
7927        Ok(())
7928    }
7929
7930    // -- persistence write-through --------------------------------------------
7931
7932    fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
7933        let state = serde_json::json!({
7934            "requested": scope_map_to_json(&sub.requested),
7935            "params": sub.params,
7936            "cursor": sub.cursor,
7937            "bootstrapState": sub.bootstrap_state,
7938            "status": sub.state.name(),
7939            "reasonCode": sub.reason_code,
7940            "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
7941            "syncedOnce": sub.synced_once,
7942        });
7943        self.conn.execute(
7944            "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
7945            rusqlite::params![sub.id, sub.table, state.to_string()],
7946        ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7947    }
7948
7949    fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
7950        let ops: Vec<Value> = commit
7951            .ops
7952            .iter()
7953            .map(|op| {
7954                serde_json::json!({
7955                    "op": if op.upsert { "upsert" } else { "delete" },
7956                    "table": op.table,
7957                    "rowId": op.row_id,
7958                    "baseVersion": op.base_version,
7959                    "values": op.values.clone().map(Value::Object),
7960                })
7961            })
7962            .collect();
7963        self.conn
7964            .execute(
7965                "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
7966                rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
7967            )
7968            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7969        for operation in &commit.ops {
7970            let Some(values) = &operation.values else {
7971                continue;
7972            };
7973            let Some(table) = self.schema.table(&operation.table) else {
7974                continue;
7975            };
7976            for column in table
7977                .columns
7978                .iter()
7979                .filter(|column| column.ty == ColumnType::BlobRef)
7980            {
7981                let Some(Value::String(raw)) = values.get(&column.name) else {
7982                    continue;
7983                };
7984                let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
7985                    continue;
7986                };
7987                let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
7988                    continue;
7989                };
7990                self.conn
7991                    .execute(
7992                        "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
7993                         SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
7994                        rusqlite::params![commit.client_commit_id, blob_id],
7995                    )
7996                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
7997            }
7998        }
7999        Ok(())
8000    }
8001
8002    fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
8003        if let Some(commit) = self
8004            .outbox
8005            .iter()
8006            .find(|commit| commit.client_commit_id == client_commit_id)
8007        {
8008            for operation in &commit.ops {
8009                self.overlay_dirty.table(&operation.table);
8010            }
8011        }
8012
8013        self.conn
8014            .execute(
8015                "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
8016                rusqlite::params![client_commit_id],
8017            )
8018            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8019        self.conn
8020            .execute(
8021                "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
8022                rusqlite::params![client_commit_id],
8023            )
8024            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8025        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)", [])
8026            .map(|_| ())
8027            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8028    }
8029
8030    fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
8031        match status {
8032            CommitOutcomeStatus::Applied => "applied",
8033            CommitOutcomeStatus::Cached => "cached",
8034            CommitOutcomeStatus::Conflict => "conflict",
8035            CommitOutcomeStatus::Rejected => "rejected",
8036        }
8037    }
8038
8039    fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
8040        match resolution {
8041            CommitOutcomeResolution::Active => "active",
8042            CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
8043            CommitOutcomeResolution::Superseded => "superseded",
8044            CommitOutcomeResolution::Dismissed => "dismissed",
8045        }
8046    }
8047
8048    fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
8049        match value {
8050            "applied" => Ok(CommitOutcomeStatus::Applied),
8051            "cached" => Ok(CommitOutcomeStatus::Cached),
8052            "conflict" => Ok(CommitOutcomeStatus::Conflict),
8053            "rejected" => Ok(CommitOutcomeStatus::Rejected),
8054            _ => Err(format!("invalid persisted commit outcome status {value:?}")),
8055        }
8056    }
8057
8058    fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
8059        match value {
8060            "active" => Ok(CommitOutcomeResolution::Active),
8061            "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
8062            "superseded" => Ok(CommitOutcomeResolution::Superseded),
8063            "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
8064            _ => Err(format!(
8065                "invalid persisted commit outcome resolution {value:?}"
8066            )),
8067        }
8068    }
8069
8070    fn persist_commit_outcome(
8071        &self,
8072        client_commit_id: &str,
8073        status: CommitOutcomeStatus,
8074        results: &[CommitOperationOutcome],
8075        operations: Option<&[OutboxOp]>,
8076    ) -> Result<(), String> {
8077        let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
8078        let operations_json = operations
8079            .map(|items| {
8080                serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
8081            })
8082            .transpose()
8083            .map_err(|error| error.to_string())?;
8084        self.conn
8085            .execute(
8086                "INSERT INTO _syncular_commit_outcomes (
8087                   client_commit_id, status, recorded_at_ms, results_json,
8088                   operations_json, resolution
8089                 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
8090                rusqlite::params![
8091                    client_commit_id,
8092                    Self::outcome_status_name(status),
8093                    self.clock_now_ms(),
8094                    results_json,
8095                    operations_json
8096                ],
8097            )
8098            .map(|_| ())
8099            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8100    }
8101
8102    fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
8103        let StoredCommitOutcomeRow {
8104            sequence,
8105            client_commit_id,
8106            status,
8107            recorded_at_ms,
8108            results_json,
8109            operations_json,
8110            resolution,
8111            resolved_at_ms,
8112            replacement_client_commit_id,
8113        } = row;
8114        Ok(CommitOutcome {
8115            sequence,
8116            client_commit_id,
8117            status: Self::parse_outcome_status(&status)?,
8118            recorded_at_ms,
8119            results: serde_json::from_str(&results_json)
8120                .map_err(|error| format!("invalid persisted commit outcome results: {error}"))?,
8121            operations: operations_json
8122                .map(|value| {
8123                    serde_json::from_str(&value).map_err(|error| {
8124                        format!("invalid persisted commit outcome operations: {error}")
8125                    })
8126                })
8127                .transpose()?,
8128            retained_rows: None,
8129            resolution: Self::parse_outcome_resolution(&resolution)?,
8130            resolved_at_ms,
8131            replacement_client_commit_id,
8132        })
8133    }
8134
8135    /// Resolve declared unique keys through SQLite, including affinity and NULL semantics.
8136    fn unique_conflicts(
8137        &self,
8138        table: &TableSchema,
8139        values: &Map<String, Value>,
8140        base: bool,
8141    ) -> Result<Vec<RetainedUniqueConflict>, String> {
8142        let source = if base {
8143            base_table(&table.name)
8144        } else {
8145            visible_table(&table.name)
8146        };
8147        let mut conflicts = Vec::new();
8148        for index in table.indexes.iter().filter(|index| index.unique) {
8149            let mut params = vec![json_param_to_sql(
8150                values.get(&table.primary_key).ok_or("sync.local_corrupt")?,
8151            )?];
8152            for name in &index.columns {
8153                let column = table
8154                    .columns
8155                    .iter()
8156                    .find(|column| column.name == *name)
8157                    .ok_or("sync.local_corrupt")?;
8158                let cell = json_to_column_value(column, values.get(name))?;
8159                params.push(owned_sql_value(RowParam::Cell(&cell))?);
8160            }
8161            let sql = format!(
8162                "SELECT * FROM {source} WHERE {} != ? AND {}",
8163                quote_ident(&table.primary_key),
8164                index
8165                    .columns
8166                    .iter()
8167                    .map(|column| format!("{} = ?", quote_ident(column)))
8168                    .collect::<Vec<_>>()
8169                    .join(" AND ")
8170            );
8171            let mut statement = self
8172                .conn
8173                .prepare_cached(&sql)
8174                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8175            let records = statement
8176                .query_map(rusqlite::params_from_iter(params), |record| {
8177                    let values = table
8178                        .columns
8179                        .iter()
8180                        .enumerate()
8181                        .map(|(index, column)| {
8182                            let value = sql_ref_to_json(column, record.get_ref(index)?);
8183                            json_to_column_value(column, Some(&value)).map_err(|message| {
8184                                rusqlite::Error::ToSqlConversionFailure(message.into())
8185                            })
8186                        })
8187                        .collect::<Result<Row, _>>()?;
8188                    Ok((values, record.get::<_, i64>(table.columns.len())?))
8189                })
8190                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8191            for record in records {
8192                let (row, version) =
8193                    record.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8194                let row_id = render_row_id_json(Some(&column_value_to_json(&row[table.pk_index])))?;
8195                conflicts.push(RetainedUniqueConflict {
8196                    index: index.name.clone(),
8197                    columns: index.columns.clone(),
8198                    row_id,
8199                    server_row: table
8200                        .columns
8201                        .iter()
8202                        .zip(&row)
8203                        .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
8204                        .collect(),
8205                    server_version: version,
8206                });
8207            }
8208        }
8209        Ok(conflicts)
8210    }
8211
8212    fn attach_retained_rows(&self, mut outcome: CommitOutcome) -> Result<CommitOutcome, String> {
8213        if let Some((commit, initial)) = self
8214            .failed_commits
8215            .iter()
8216            .find(|(commit, _)| commit.client_commit_id == outcome.client_commit_id)
8217        {
8218            let mut rows = Vec::new();
8219            for operation in &commit.ops {
8220                let table = self
8221                    .schema
8222                    .table(&operation.table)
8223                    .ok_or_else(|| "sync.unknown_table".to_owned())?;
8224                let key =
8225                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
8226                        .map_err(|_| "sync.local_corrupt".to_owned())?;
8227                let base = self.stored_row(table, &operation.row_id, true);
8228                rows.push(RetainedCommitRow {
8229                    table: operation.table.clone(),
8230                    row_id: operation.row_id.clone(),
8231                    unique_conflicts: if operation.upsert {
8232                        self.unique_conflicts(
8233                            table,
8234                            initial
8235                                .get(&key)
8236                                .and_then(Value::as_object)
8237                                .or(operation.values.as_ref())
8238                                .ok_or("sync.local_corrupt")?,
8239                            true,
8240                        )?
8241                    } else {
8242                        Vec::new()
8243                    },
8244                    local_row: if operation.upsert {
8245                        initial
8246                            .get(&key)
8247                            .and_then(Value::as_object)
8248                            .cloned()
8249                            .or_else(|| operation.values.clone())
8250                    } else {
8251                        None
8252                    },
8253                    server_row: base.as_ref().map(|(values, _)| {
8254                        table
8255                            .columns
8256                            .iter()
8257                            .zip(values)
8258                            .map(|(column, value)| {
8259                                (column.name.clone(), column_value_to_json(value))
8260                            })
8261                            .collect()
8262                    }),
8263                    server_version: base.map(|(_, version)| version),
8264                });
8265            }
8266            outcome.retained_rows = Some(rows);
8267        }
8268        Ok(outcome)
8269    }
8270
8271    pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
8272        let row = self
8273            .conn
8274            .query_row(
8275                "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
8276                        resolution, resolved_at_ms, replacement_client_commit_id
8277                   FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
8278                rusqlite::params![client_commit_id],
8279                |row| {
8280                    Ok(StoredCommitOutcomeRow {
8281                        sequence: row.get(0)?,
8282                        client_commit_id: row.get(1)?,
8283                        status: row.get(2)?,
8284                        recorded_at_ms: row.get(3)?,
8285                        results_json: row.get(4)?,
8286                        operations_json: row.get(5)?,
8287                        resolution: row.get(6)?,
8288                        resolved_at_ms: row.get(7)?,
8289                        replacement_client_commit_id: row.get(8)?,
8290                    })
8291                },
8292            )
8293            .optional()
8294            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8295        row.map(|row| self.attach_retained_rows(Self::outcome_from_row(row)?))
8296            .transpose()
8297    }
8298
8299    pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
8300        if query.limit == Some(0) {
8301            return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
8302        }
8303        let mut sql = String::from(
8304            "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
8305                    resolution, resolved_at_ms, replacement_client_commit_id
8306               FROM _syncular_commit_outcomes",
8307        );
8308        if query.active_only {
8309            sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
8310        }
8311        sql.push_str(" ORDER BY seq DESC");
8312        if let Some(limit) = query.limit {
8313            sql.push_str(&format!(" LIMIT {limit}"));
8314        }
8315        let mut stmt = self
8316            .conn
8317            .prepare(&sql)
8318            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8319        let rows = stmt
8320            .query_map([], |row| {
8321                Ok(StoredCommitOutcomeRow {
8322                    sequence: row.get(0)?,
8323                    client_commit_id: row.get(1)?,
8324                    status: row.get(2)?,
8325                    recorded_at_ms: row.get(3)?,
8326                    results_json: row.get(4)?,
8327                    operations_json: row.get(5)?,
8328                    resolution: row.get(6)?,
8329                    resolved_at_ms: row.get(7)?,
8330                    replacement_client_commit_id: row.get(8)?,
8331                })
8332            })
8333            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8334        let mut outcomes = Vec::new();
8335        for row in rows {
8336            outcomes.push(self.attach_retained_rows(Self::outcome_from_row(
8337                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
8338            )?)?);
8339        }
8340        Ok(outcomes)
8341    }
8342
8343    fn prune_commit_outcomes(&self) -> Result<(), String> {
8344        #[cfg(test)]
8345        self.outcome_prune_count
8346            .set(self.outcome_prune_count.get() + 1);
8347        let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
8348        let count = self
8349            .conn
8350            .query_row(
8351                "SELECT COUNT(*) FROM _syncular_commit_outcomes",
8352                [],
8353                |row| row.get::<_, i64>(0),
8354            )
8355            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
8356            as usize;
8357        let excess = count.saturating_sub(max_entries);
8358        if excess == 0 {
8359            return Ok(());
8360        }
8361        let mut stmt = self
8362            .conn
8363            .prepare(
8364                "SELECT seq FROM _syncular_commit_outcomes
8365                  WHERE status IN ('applied', 'cached') OR resolution != 'active'
8366                  ORDER BY seq ASC LIMIT ?1",
8367            )
8368            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8369        let rows = stmt
8370            .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
8371            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8372        let sequences = rows
8373            .collect::<Result<Vec<_>, _>>()
8374            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8375        drop(stmt);
8376        for sequence in sequences {
8377            self.conn
8378                .execute(
8379                    "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
8380                    rusqlite::params![sequence],
8381                )
8382                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8383        }
8384        Ok(())
8385    }
8386
8387    // -- driver surface ---------------------------------------------------------
8388
8389    pub fn subscribe(
8390        &mut self,
8391        id: String,
8392        table: String,
8393        scopes: Vec<(String, Vec<String>)>,
8394        params: Option<String>,
8395    ) -> Result<(), String> {
8396        if self.schema.table(&table).is_none() {
8397            return Err(format!("unknown table {table:?}"));
8398        }
8399        if let Some(existing) = self.subs.iter().find(|subscription| subscription.id == id) {
8400            let same_intent = existing.table == table
8401                && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
8402                && existing.params == params;
8403            if same_intent {
8404                return Ok(());
8405            }
8406            return Err(
8407                "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
8408                    .to_owned(),
8409            );
8410        }
8411        let sub = Subscription {
8412            id: id.clone(),
8413            table,
8414            requested: scopes,
8415            params,
8416            cursor: -1,
8417            bootstrap_state: None,
8418            state: SubState::Active,
8419            reason_code: None,
8420            effective: None,
8421            synced_once: false,
8422        };
8423        self.persist_sub(&sub)?;
8424        self.subs.push(sub);
8425        Ok(())
8426    }
8427
8428    pub fn unsubscribe(&mut self, id: &str) {
8429        if self.subs.iter().any(|s| s.id == id) {
8430            self.cancel_sync_round();
8431        }
8432        self.subs.retain(|s| s.id != id);
8433        let _ = self.conn.execute(
8434            "DELETE FROM _syncular_subscriptions WHERE id = ?1",
8435            rusqlite::params![id],
8436        );
8437    }
8438
8439    // -- windowed subscriptions (§4.8) ------------------------------------------
8440
8441    /// §4.8: set the live window units for a base — a value-sharded family
8442    /// of subscriptions, one per unit. Added units get fresh subscriptions
8443    /// (image-lane bootstrap on the next sync); removed units are
8444    /// unsubscribed with their first eviction chunk in one transaction (E1–E4).
8445    /// Idempotent; re-entry cancels any deferred eviction.
8446    pub fn set_window(
8447        &mut self,
8448        base: &WindowBase,
8449        units: &[String],
8450    ) -> Result<CommandEffects, String> {
8451        let table = self
8452            .schema
8453            .table(&base.table)
8454            .ok_or_else(|| format!("unknown table {:?}", base.table))?;
8455        if table.scope_column(&base.variable).is_none() {
8456            return Err(format!(
8457                "setWindow: table {:?} has no scope variable {:?} (§4.8)",
8458                base.table, base.variable
8459            ));
8460        }
8461        let base_key = window_base_key(base);
8462        let wanted: std::collections::HashSet<&String> = units.iter().collect();
8463        let live = self.load_window_units(&base_key);
8464        self.begin_observation("syncular_window")?;
8465        let mut batch = ChangeAccumulator::default();
8466        let mut changed = false;
8467
8468        let prior_subs = self.subs.clone();
8469        let widened = (|| {
8470            // Widen: units wanted but not live → fresh subscription + registry row.
8471            for unit in units {
8472                if live.iter().any(|(u, _)| u == unit) {
8473                    continue;
8474                }
8475                let sub_id = derive_sub_id(base, unit);
8476                self.delete_pending_evict(&sub_id)?;
8477                self.insert_window_unit(&base_key, unit, &sub_id)?;
8478                self.subscribe(
8479                    sub_id,
8480                    base.table.clone(),
8481                    unit_scopes(base, unit),
8482                    base.params.clone(),
8483                )?;
8484                batch.window(&base_key, &base.table, unit);
8485                changed = true;
8486            }
8487
8488            self.finish_observation("syncular_window", batch)
8489        })();
8490        if let Err(error) = widened {
8491            self.rollback_observation("syncular_window");
8492            self.subs = prior_subs;
8493            return Err(error);
8494        }
8495        for (unit, sub_id) in live {
8496            if wanted.contains(&unit) {
8497                continue;
8498            }
8499            self.rebuild_overlay_if_dirty();
8500            let prior_subs = self.subs.clone();
8501            self.begin_observation("syncular_window")?;
8502            let effective = self
8503                .subs
8504                .iter()
8505                .find(|sub| sub.id == sub_id)
8506                .and_then(|sub| sub.effective.clone())
8507                .unwrap_or_else(|| unit_scopes(base, &unit));
8508            let mut batch = ChangeAccumulator::default();
8509            self.record_scope_map(&mut batch, &base.table, &effective);
8510            batch.window(&base_key, &base.table, &unit);
8511            let result = self.evict_unit(&base_key, base, &unit, &sub_id);
8512            let remaining = match result {
8513                Ok(remaining) => remaining,
8514                Err(error) => {
8515                    self.rollback_observation("syncular_window");
8516                    self.subs = prior_subs;
8517                    return Err(error);
8518                }
8519            };
8520            if let Err(error) = self.finish_observation("syncular_window", batch) {
8521                self.rollback_observation("syncular_window");
8522                self.subs = prior_subs;
8523                return Err(error);
8524            }
8525            if remaining {
8526                self.drain_pending_evictions()?;
8527            }
8528            changed = true;
8529        }
8530        Ok(if changed {
8531            CommandEffects::interactive()
8532        } else {
8533            CommandEffects::none()
8534        })
8535    }
8536
8537    /// §4.8 completeness oracle (I3): the windowed-in units for a base plus
8538    /// the subset still bootstrap-pending. Registration alone is not
8539    /// completeness — a unit is pending until its subscription completes a
8540    /// bootstrap round (cursor advances past -1 with no resume token held).
8541    pub fn window_state(&self, base: &WindowBase) -> WindowState {
8542        let mut units = Vec::new();
8543        let mut pending = Vec::new();
8544        for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
8545            let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
8546                Some(sub) => {
8547                    sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
8548                }
8549                None => true,
8550            };
8551            if is_pending {
8552                pending.push(unit.clone());
8553            }
8554            units.push(unit);
8555        }
8556        WindowState { units, pending }
8557    }
8558
8559    fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
8560        let mut stmt = match self
8561            .conn
8562            .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
8563        {
8564            Ok(stmt) => stmt,
8565            Err(_) => return Vec::new(),
8566        };
8567        let rows = stmt.query_map(rusqlite::params![base_key], |row| {
8568            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
8569        });
8570        match rows {
8571            Ok(rows) => rows.filter_map(Result::ok).collect(),
8572            Err(_) => Vec::new(),
8573        }
8574    }
8575
8576    fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
8577        let mut stmt = match self.conn.prepare(
8578            "SELECT windows.base, windows.unit, subscriptions.tbl
8579               FROM _syncular_windows AS windows
8580               JOIN _syncular_subscriptions AS subscriptions
8581                 ON subscriptions.id = windows.sub_id
8582               ORDER BY windows.base, windows.unit",
8583        ) {
8584            Ok(stmt) => stmt,
8585            Err(_) => return Vec::new(),
8586        };
8587        let rows = stmt.query_map([], |row| {
8588            Ok((
8589                row.get::<_, String>(0)?,
8590                row.get::<_, String>(1)?,
8591                row.get::<_, String>(2)?,
8592            ))
8593        });
8594        match rows {
8595            Ok(rows) => rows.filter_map(Result::ok).collect(),
8596            Err(_) => Vec::new(),
8597        }
8598    }
8599
8600    fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
8601        self.conn
8602            .query_row(
8603                "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
8604                rusqlite::params![sub_id],
8605                |row| Ok((row.get(0)?, row.get(1)?)),
8606            )
8607            .ok()
8608    }
8609
8610    fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
8611        self.conn
8612            .execute(
8613                "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
8614                rusqlite::params![base_key, unit, sub_id],
8615            )
8616            .map(|_| ())
8617            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8618    }
8619
8620    fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
8621        self.conn
8622            .execute(
8623                "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
8624                rusqlite::params![base_key, unit],
8625            )
8626            .map(|_| ())
8627            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8628    }
8629
8630    /// §4.8 E1–E4: evict one departing unit, fused with unsubscription.
8631    /// Deletes the unit's rows except those pinned by a pending outbox
8632    /// commit (E1); records a deferred eviction if any pin remains; discards
8633    /// the subscription's cursor/resume/effective-echo (E3) and version
8634    /// state with the rows (E2). Fail-closed: no local mapping ⇒ evict
8635    /// nothing.
8636    fn evict_unit(
8637        &mut self,
8638        base_key: &str,
8639        base: &WindowBase,
8640        unit: &str,
8641        sub_id: &str,
8642    ) -> Result<bool, String> {
8643        let effective = self
8644            .subs
8645            .iter()
8646            .find(|s| s.id == sub_id)
8647            .and_then(|s| s.effective.clone())
8648            .unwrap_or_else(|| unit_scopes(base, unit));
8649        let pinned = self.pinned_row_ids(&base.table);
8650        let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
8651        self.delete_window_unit(base_key, unit)?;
8652        self.conn
8653            .execute(
8654                "DELETE FROM _syncular_subscriptions WHERE id = ?1",
8655                [sub_id],
8656            )
8657            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8658        self.subs.retain(|sub| sub.id != sub_id);
8659        if deferred {
8660            self.save_pending_evict(sub_id, &base.table, &effective)?;
8661        } else {
8662            self.delete_pending_evict(sub_id)?;
8663        }
8664        Ok(remaining)
8665    }
8666
8667    /// §4.8 E1: delete base rows matching effective scopes EXCEPT pinned
8668    /// primary keys. Returns (more unpinned work, any scoped rows remain).
8669    /// A missing scope-column mapping fails without deleting rows.
8670    fn evict_scope_rows(
8671        &self,
8672        table_name: &str,
8673        effective: &[(String, Vec<String>)],
8674        pinned: &std::collections::HashSet<String>,
8675    ) -> Result<(bool, bool), String> {
8676        if effective.is_empty() {
8677            return Ok((false, false));
8678        }
8679        let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
8680        let mut clauses = Vec::new();
8681        let mut params: Vec<SqlValue> = Vec::new();
8682        for (variable, values) in effective {
8683            let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
8684            if values.is_empty() {
8685                return Ok((false, false));
8686            }
8687            let holes = values
8688                .iter()
8689                .map(|v| {
8690                    params.push(SqlValue::Text(v.clone()));
8691                    "?"
8692                })
8693                .collect::<Vec<_>>()
8694                .join(", ");
8695            clauses.push(format!("{} IN ({holes})", quote_ident(column)));
8696        }
8697        let scope = clauses.join(" AND ");
8698        let scope_params = params.len();
8699        let pk = quote_ident(&table.primary_key);
8700        if !pinned.is_empty() {
8701            let holes = pinned
8702                .iter()
8703                .map(|id| {
8704                    params.push(SqlValue::Text(id.clone()));
8705                    "?"
8706                })
8707                .collect::<Vec<_>>()
8708                .join(", ");
8709            clauses.push(format!("{pk} NOT IN ({holes})"));
8710        }
8711        let predicate = clauses.join(" AND ");
8712        let base = base_table(table_name);
8713        let visible = visible_table(table_name);
8714        let mut ids: Vec<SqlValue> = Vec::new();
8715        for source in [&base, &visible] {
8716            let mut stmt = self
8717                .conn
8718                .prepare_cached(&format!(
8719                    "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
8720                ))
8721                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8722            ids = stmt
8723                .query_map(rusqlite::params_from_iter(&params), |row| row.get(0))
8724                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
8725                .collect::<Result<_, _>>()
8726                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8727            if !ids.is_empty() {
8728                break;
8729            }
8730        }
8731        if !ids.is_empty() {
8732            for id in &ids {
8733                self.conn
8734                    .execute(
8735                        "DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2",
8736                        rusqlite::params![table_name, id],
8737                    )
8738                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8739            }
8740            let holes = vec!["?"; ids.len()].join(", ");
8741            for source in [&base, &visible] {
8742                self.conn
8743                    .execute(
8744                        &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
8745                        rusqlite::params_from_iter(&ids),
8746                    )
8747                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8748            }
8749        }
8750        let mut remaining = false;
8751        let mut deferred = false;
8752        for source in [&base, &visible] {
8753            remaining |= self
8754                .conn
8755                .query_row(
8756                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
8757                    rusqlite::params_from_iter(&params),
8758                    |row| row.get::<_, bool>(0),
8759                )
8760                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8761            deferred |= self
8762                .conn
8763                .query_row(
8764                    &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
8765                    rusqlite::params_from_iter(&params[..scope_params]),
8766                    |row| row.get::<_, bool>(0),
8767                )
8768                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8769        }
8770        Ok((remaining, deferred))
8771    }
8772
8773    /// Resume committed eviction chunks, retaining outbox pins and their durable marker.
8774    fn drain_pending_evictions(&mut self) -> Result<(), String> {
8775        let pending = self.load_pending_evictions()?;
8776        if pending.is_empty() {
8777            return Ok(());
8778        }
8779        self.rebuild_overlay_if_dirty();
8780        for (sub_id, table_name, effective) in pending {
8781            if self.schema.table(&table_name).is_none() {
8782                self.delete_pending_evict(&sub_id)?;
8783                continue;
8784            }
8785            loop {
8786                std::thread::yield_now();
8787                self.begin_observation("syncular_evict")?;
8788                let pinned = self.pinned_row_ids(&table_name);
8789                let result = self.evict_scope_rows(&table_name, &effective, &pinned);
8790                let (remaining, deferred) = match result {
8791                    Ok(result) => result,
8792                    Err(error) => {
8793                        self.rollback_observation("syncular_evict");
8794                        return Err(error);
8795                    }
8796                };
8797                if !deferred {
8798                    if let Err(error) = self.delete_pending_evict(&sub_id) {
8799                        self.rollback_observation("syncular_evict");
8800                        return Err(error);
8801                    }
8802                }
8803                let mut batch = ChangeAccumulator::default();
8804                self.record_scope_map(&mut batch, &table_name, &effective);
8805                if let Err(error) = self.finish_observation("syncular_evict", batch) {
8806                    self.rollback_observation("syncular_evict");
8807                    return Err(error);
8808                }
8809                if !remaining {
8810                    break;
8811                }
8812            }
8813        }
8814        Ok(())
8815    }
8816
8817    /// §4.8 E1: primary keys of `table` referenced by a pending outbox
8818    /// commit — rows that MUST NOT be evicted until the commit drains.
8819    fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
8820        let mut pinned = std::collections::HashSet::new();
8821        for commit in self
8822            .outbox
8823            .iter()
8824            .chain(self.failed_commits.iter().map(|(commit, _)| commit))
8825        {
8826            for op in &commit.ops {
8827                if op.table == table {
8828                    pinned.insert(op.row_id.clone());
8829                }
8830            }
8831        }
8832        pinned
8833    }
8834
8835    fn save_pending_evict(
8836        &self,
8837        sub_id: &str,
8838        table: &str,
8839        effective: &[(String, Vec<String>)],
8840    ) -> Result<(), String> {
8841        self.conn.execute(
8842            "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
8843            rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
8844        ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8845    }
8846
8847    fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
8848        self.conn
8849            .execute(
8850                "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
8851                rusqlite::params![sub_id],
8852            )
8853            .map(|_| ())
8854            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
8855    }
8856
8857    fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
8858        let mut stmt = self
8859            .conn
8860            .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
8861            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8862        let rows = stmt
8863            .query_map([], |row| {
8864                Ok((
8865                    row.get::<_, String>(0)?,
8866                    row.get::<_, String>(1)?,
8867                    row.get::<_, String>(2)?,
8868                ))
8869            })
8870            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8871        rows.map(|row| {
8872            let (sub_id, table, json) =
8873                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8874            let value: Value = serde_json::from_str(&json)
8875                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
8876            let effective = json_to_scope_map(&value)
8877                .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
8878            Ok((sub_id, table, effective))
8879        })
8880        .collect()
8881    }
8882
8883    /// Select failed-intent retention; reload durable retained aggregates.
8884    pub fn set_retain_failed_commits(&mut self, enabled: bool) -> Result<(), String> {
8885        self.retain_failed_commits = enabled;
8886        for (commit, _) in &self.failed_commits {
8887            for operation in &commit.ops {
8888                self.overlay_dirty.table(&operation.table);
8889            }
8890        }
8891        self.failed_commits = self.load_failed_commits()?;
8892        for (commit, _) in &self.failed_commits {
8893            for operation in &commit.ops {
8894                self.overlay_dirty.table(&operation.table);
8895            }
8896        }
8897        Ok(())
8898    }
8899
8900    fn load_failed_commits(&self) -> Result<Vec<FailedCommit>, String> {
8901        let mut statement = self.conn.prepare("SELECT client_commit_id, operations_json, initial_json FROM _syncular_failed_commits ORDER BY seq")
8902            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8903        let rows = statement
8904            .query_map([], |row| {
8905                Ok((
8906                    row.get::<_, String>(0)?,
8907                    row.get::<_, String>(1)?,
8908                    row.get::<_, String>(2)?,
8909                ))
8910            })
8911            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8912        rows.map(|row| {
8913            let (id, operations, initial) =
8914                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8915            let operations: Vec<CommitOperation> =
8916                serde_json::from_str(&operations).map_err(|_| "sync.local_corrupt".to_owned())?;
8917            let initial =
8918                serde_json::from_str(&initial).map_err(|_| "sync.local_corrupt".to_owned())?;
8919            Ok((
8920                OutboxCommit {
8921                    client_commit_id: id,
8922                    ops: operations
8923                        .into_iter()
8924                        .map(|op| OutboxOp {
8925                            upsert: op.op == "upsert",
8926                            table: op.table,
8927                            row_id: op.row_id,
8928                            base_version: op.base_version,
8929                            values: op.values,
8930                        })
8931                        .collect(),
8932                },
8933                initial,
8934            ))
8935        })
8936        .collect()
8937    }
8938
8939    fn retain_failed_commit(&mut self, id: &str, operations: &[OutboxOp]) -> Result<(), String> {
8940        let mut initial = Map::new();
8941        for operation in operations {
8942            let table = self
8943                .schema
8944                .table(&operation.table)
8945                .ok_or_else(|| "sync.unknown_table".to_owned())?;
8946            if let Some((values, _)) = self
8947                .stored_row(table, &operation.row_id, true)
8948                .or_else(|| self.visible_row(table, &operation.row_id))
8949            {
8950                let mut intended: Map<String, Value> = table
8951                    .columns
8952                    .iter()
8953                    .zip(values.iter())
8954                    .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
8955                    .collect();
8956                if let Some(patch) = &operation.values {
8957                    intended.extend(patch.clone());
8958                }
8959                initial.insert(
8960                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
8961                        .map_err(|_| "sync.local_corrupt".to_owned())?,
8962                    Value::Object(intended),
8963                );
8964            }
8965        }
8966        self.conn.execute("INSERT INTO _syncular_failed_commits(client_commit_id, operations_json, initial_json) VALUES (?1, ?2, ?3)", rusqlite::params![id,
8967            serde_json::to_string(&operations.iter().map(CommitOperation::from).collect::<Vec<_>>()).map_err(|_| "sync.local_corrupt".to_owned())?,
8968            serde_json::to_string(&initial).map_err(|_| "sync.local_corrupt".to_owned())?])
8969            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8970        self.failed_commits.push((
8971            OutboxCommit {
8972                client_commit_id: id.to_owned(),
8973                ops: operations.to_vec(),
8974            },
8975            initial,
8976        ));
8977        Ok(())
8978    }
8979
8980    fn drop_failed_commits(
8981        &mut self,
8982        matches: impl Fn(&str, &Map<String, Value>) -> bool,
8983    ) -> Result<Vec<OutboxCommit>, String> {
8984        let mut doomed = self.drop_acknowledged_rows(&matches)?;
8985        for (commit, initial) in &self.failed_commits {
8986            for operation in &commit.ops {
8987                let key =
8988                    serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
8989                        .map_err(|_| "sync.local_corrupt".to_owned())?;
8990                if initial
8991                    .get(&key)
8992                    .and_then(Value::as_object)
8993                    .is_some_and(|values| matches(&operation.table, values))
8994                {
8995                    doomed.push(commit.clone());
8996                    break;
8997                }
8998            }
8999        }
9000        for commit in &doomed {
9001            self.conn
9002                .execute(
9003                    "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
9004                    rusqlite::params![commit.client_commit_id],
9005                )
9006                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9007        }
9008        for commit in &doomed {
9009            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))?;
9010        }
9011        self.conflicts.retain(|record| {
9012            !doomed
9013                .iter()
9014                .any(|commit| commit.client_commit_id == record.client_commit_id)
9015        });
9016        self.rejections.retain(|record| {
9017            !doomed
9018                .iter()
9019                .any(|commit| commit.client_commit_id == record.client_commit_id)
9020        });
9021        self.failed_commits.retain(|(commit, _)| {
9022            !doomed
9023                .iter()
9024                .any(|dropped| dropped.client_commit_id == commit.client_commit_id)
9025        });
9026        for commit in &doomed {
9027            for operation in &commit.ops {
9028                self.overlay_dirty.table(&operation.table);
9029            }
9030        }
9031        Ok(doomed)
9032    }
9033
9034    pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, String> {
9035        if mutations.is_empty() {
9036            return Err("a commit must contain at least one operation (§6.1)".to_owned());
9037        }
9038        let mut ops = Vec::with_capacity(mutations.len());
9039        for mutation in mutations {
9040            match mutation {
9041                Mutation::Patch {
9042                    table,
9043                    values,
9044                    base_version,
9045                } => {
9046                    let schema_table = self
9047                        .schema
9048                        .table(&table)
9049                        .ok_or_else(|| "sync.unknown_table".to_owned())?;
9050                    let values = normalize_values_casing(schema_table, values)?;
9051                    let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
9052                    ops.push(self.prepare_patch(&table, &row_id, values, base_version)?);
9053                }
9054                Mutation::Upsert {
9055                    table,
9056                    values,
9057                    base_version,
9058                } => {
9059                    let schema_table = self
9060                        .schema
9061                        .table(&table)
9062                        .ok_or_else(|| format!("unknown table {table:?}"))?;
9063                    // §5: value keys are accepted in snake_case AND the
9064                    // generated row types' camelCase; normalize to SQL truth
9065                    // before the pk lookup / codec see them.
9066                    let values = normalize_values_casing(schema_table, values)?;
9067                    let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
9068                    // §6.7 `mutate` marks every column present: fill missing
9069                    // nullable columns with NULL before the payload is built.
9070                    let values = full_row_values(schema_table, values)?;
9071                    // §5.11: validate the payload encodes with the current
9072                    // codec. A key-selection or unknown-key failure is a
9073                    // durable rejection at the push seam (§10.3), never an
9074                    // author-time error. Full-row `mutate` presents every
9075                    // column, so no stored fallback is needed.
9076                    self.validate_author_encode(schema_table, &row_id, &values)?;
9077                    ops.push(OutboxOp {
9078                        upsert: true,
9079                        table,
9080                        row_id,
9081                        base_version,
9082                        values: Some(values),
9083                    });
9084                }
9085                Mutation::Delete {
9086                    table,
9087                    row_id,
9088                    base_version,
9089                } => {
9090                    if self.schema.table(&table).is_none() {
9091                        return Err(format!("unknown table {table:?}"));
9092                    }
9093                    ops.push(OutboxOp {
9094                        upsert: false,
9095                        table,
9096                        row_id,
9097                        base_version,
9098                        values: None,
9099                    });
9100                }
9101            }
9102        }
9103        self.record_outbox_commit(ops)
9104    }
9105
9106    fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
9107        let commit = OutboxCommit {
9108            client_commit_id: uuid::Uuid::new_v4().to_string(),
9109            ops,
9110        };
9111        self.begin_observation("syncular_mutation")?;
9112        let mut batch = ChangeAccumulator::default();
9113        for op in &commit.ops {
9114            let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
9115            if let Some(values) = &op.values {
9116                if let Some(table) = self.schema.table(&op.table) {
9117                    for scope in &table.scope_variables {
9118                        if let Some(Value::String(value)) = values.get(&scope.column) {
9119                            batch.scope(&op.table, format!("{}:{value}", scope.prefix));
9120                            precise = true;
9121                        }
9122                    }
9123                }
9124            }
9125            if !precise {
9126                batch.table(&op.table);
9127            }
9128        }
9129        if let Err(error) = self.persist_outbox_insert(&commit) {
9130            self.rollback_observation("syncular_mutation");
9131            return Err(error);
9132        }
9133        let was_dirty = self.overlay_dirty.snapshot();
9134        // A clean overlay contains the FIFO fold of every previous commit, so
9135        // the new commit applies over it. §7.1: an operation that does not
9136        // apply (a secondary unique collision) fails the whole commit; the
9137        // savepoint rollback removes the outbox entry and every visible write.
9138        if self.overlay_dirty.get() {
9139            self.rebuild_overlay();
9140        }
9141        if let Some(error) = commit
9142            .ops
9143            .iter()
9144            .find_map(|op| self.apply_outbox_op(op).err())
9145        {
9146            self.rollback_observation("syncular_mutation");
9147            self.overlay_dirty.restore(was_dirty.clone());
9148            return Err(error);
9149        }
9150        let id = commit.client_commit_id.clone();
9151        self.outbox.push(commit);
9152        batch.status = true;
9153        if let Err(error) = self.finish_observation("syncular_mutation", batch) {
9154            self.rollback_observation("syncular_mutation");
9155            self.outbox.pop();
9156            self.overlay_dirty.restore(was_dirty.clone());
9157            return Err(error);
9158        }
9159        Ok(id)
9160    }
9161
9162    /// Record one §6.1 sparse upsert: the primary key plus the supplied
9163    /// non-scope columns are present; every other column is absent and stays
9164    /// untouched on the server and in the local overlay. An absent local base
9165    /// refuses the whole batch with sync.row_missing before enqueueing (§7.1).
9166    pub fn patch(
9167        &mut self,
9168        table: &str,
9169        row_id: &str,
9170        partial: Map<String, Value>,
9171        base_version: Option<i64>,
9172    ) -> Result<String, String> {
9173        let operation = self.prepare_patch(table, row_id, partial, base_version)?;
9174        self.record_outbox_commit(vec![operation])
9175    }
9176
9177    fn prepare_patch(
9178        &self,
9179        table: &str,
9180        row_id: &str,
9181        partial: Map<String, Value>,
9182        base_version: Option<i64>,
9183    ) -> Result<OutboxOp, String> {
9184        let schema_table = self
9185            .schema
9186            .table(table)
9187            .ok_or_else(|| format!("unknown table {table:?}"))?;
9188        let partial = normalize_values_casing(schema_table, partial)?;
9189        if let Some(pk) = partial.get(&schema_table.primary_key) {
9190            if pk.as_str() != Some(row_id) {
9191                return Err(format!(
9192                    "sync.invalid_request: table {table:?}: patch cannot change the primary key"
9193                ));
9194            }
9195        }
9196        // Existence reads only the key. Locked encrypted values stay opaque;
9197        // decoding the complete row here would reject a valid plain patch.
9198        let exists: bool = self
9199            .conn
9200            .query_row(
9201                &format!(
9202                    "SELECT EXISTS(SELECT 1 FROM {} WHERE {})",
9203                    visible_table(table),
9204                    row_id_predicate(schema_table)
9205                ),
9206                rusqlite::params![row_id],
9207                |row| row.get(0),
9208            )
9209            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9210        if !exists {
9211            return Err("sync.row_missing: a sparse patch requires a local row".to_owned());
9212        }
9213        // §3.4 rule 5 / §6.2: scope columns are immutable on update. The
9214        // server accepts a present scope column whose value equals the
9215        // stored row and applies it as a no-op, so a decoded envelope
9216        // round-trips. The local row is the only value the client can prove
9217        // equality against; when the row is absent locally the client cannot
9218        // prove equality against the server's stored row and fails closed.
9219        // A primary key that is also a scope column needs no stored-row
9220        // read: the primary key in a sparse payload is by construction the
9221        // row id being patched (§6.1), so its value is proven equal already.
9222        for scope in &schema_table.scope_variables {
9223            if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
9224                continue;
9225            }
9226            let Some(column) = schema_table
9227                .columns
9228                .iter()
9229                .find(|column| column.name == scope.column)
9230            else {
9231                continue;
9232            };
9233            let stored = self
9234                .conn
9235                .query_row(
9236                    &format!(
9237                        "SELECT {} FROM {} WHERE {}",
9238                        quote_ident(&scope.column),
9239                        visible_table(&schema_table.name),
9240                        row_id_predicate(schema_table)
9241                    ),
9242                    rusqlite::params![row_id],
9243                    |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
9244                )
9245                .ok();
9246            // Decode the supplied value through the same column-type path the
9247            // rest of the patch encoding uses, so a value that only equals
9248            // the stored one in another number representation still compares
9249            // equal.
9250            let incoming = json_to_column_value(column, partial.get(&scope.column))
9251                .ok()
9252                .map(|value| column_value_to_json(&value));
9253            // A stored blob never equals a patch value, matching the server's
9254            // scope-column comparison (§3.4): bytes are unstorable as scope
9255            // values.
9256            let equal = stored.as_ref().is_some_and(|stored| {
9257                stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
9258            });
9259            if !equal {
9260                return Err(format!(
9261                    "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
9262                    scope.column
9263                ));
9264            }
9265        }
9266        let mut values = partial;
9267        // Presence-set semantics: a proven-equal scope column is dropped, so
9268        // the patch leaves the stored value untouched (§6.2).
9269        for scope in &schema_table.scope_variables {
9270            values.remove(&scope.column);
9271        }
9272        values.insert(
9273            schema_table.primary_key.clone(),
9274            Value::from(row_id.to_owned()),
9275        );
9276        // §5.11: structural validation only; an absent key-id selector is
9277        // resolved at the push seam from the stored local row, and a
9278        // key-selection or unknown-key failure becomes a durable
9279        // `client.encrypt_failed` rejection there (§10.3), never an
9280        // author-time error.
9281        self.validate_author_encode(schema_table, row_id, &values)?;
9282        Ok(OutboxOp {
9283            upsert: true,
9284            table: table.to_owned(),
9285            row_id: row_id.to_owned(),
9286            base_version,
9287            values: Some(values),
9288        })
9289    }
9290
9291    /// §5.11 key-id fallback: the stored local row's selector value as a
9292    /// positional row (only the selector slot filled). Returns None when no
9293    /// selector is configured, the row is locally absent, or the stored
9294    /// value is not a usable key id.
9295    fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
9296        let selector = self.encryption.key_id_columns.get(&table.name)?;
9297        let index = table
9298            .columns
9299            .iter()
9300            .position(|column| &column.name == selector)?;
9301        let sql = format!(
9302            "SELECT {} FROM {} WHERE {}",
9303            quote_ident(selector),
9304            visible_table(&table.name),
9305            row_id_predicate(table)
9306        );
9307        let key: Option<String> = self
9308            .conn
9309            .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
9310            .ok()?;
9311        let key = key.filter(|key| !key.is_empty())?;
9312        let mut fallback: Row = vec![None; table.columns.len()];
9313        fallback[index] = Some(ColumnValue::String(key));
9314        Some(fallback)
9315    }
9316
9317    /// §5.11/§10.3 author-time encode check: a structural failure (a
9318    /// non-nullable NULL, a value the codec rejects) is an author-time
9319    /// error; a key-selection or unknown-key failure is deferred to the push
9320    /// seam, where it becomes a durable `client.encrypt_failed` rejection.
9321    fn validate_author_encode(
9322        &self,
9323        table: &TableSchema,
9324        row_id: &str,
9325        values: &Map<String, Value>,
9326    ) -> Result<(), String> {
9327        match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
9328            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
9329            result => result.map(|_| ()),
9330        }
9331    }
9332
9333    /// §5.11/§10.3: drop every pending commit whose push encode fails with
9334    /// `client.encrypt_failed` (an unresolvable key id or an unknown key) and
9335    /// raise one durable rejection per commit. `sync()` never aborts for an
9336    /// encode failure. Mirrors §7.4.4's `drop_incompatible_outbox`, including
9337    /// undoing the dropped commit's purely-optimistic rows.
9338    fn drop_unencodable_outbox(&mut self) -> Result<bool, String> {
9339        if !self
9340            .schema
9341            .tables
9342            .iter()
9343            .any(|table| table.has_encrypted_columns())
9344        {
9345            return Ok(false);
9346        }
9347        let failures = self
9348            .outbox
9349            .iter()
9350            .filter_map(|commit| {
9351                commit
9352                    .ops
9353                    .iter()
9354                    .find_map(|op| {
9355                        let values = op.values.as_ref().filter(|_| op.upsert)?;
9356                        let table = self.schema.table(&op.table)?;
9357                        let fallback = self.stored_key_fallback(table, &op.row_id);
9358                        match encode_sparse_row_json(
9359                            table,
9360                            &op.row_id,
9361                            values,
9362                            &self.encryption,
9363                            fallback.as_ref(),
9364                        ) {
9365                            Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Some(error),
9366                            _ => None,
9367                        }
9368                    })
9369                    .map(|error| (commit.clone(), error))
9370            })
9371            .collect::<Vec<_>>();
9372        if failures.is_empty() {
9373            return Ok(false);
9374        }
9375        for (commit, message) in failures {
9376            if self.retain_failed_commits {
9377                self.retain_failed_commit(&commit.client_commit_id, &commit.ops)?;
9378            }
9379            let rejection = RejectionRecord {
9380                client_commit_id: commit.client_commit_id.clone(),
9381                op_index: 0,
9382                code: ENCRYPT_FAILED_CODE.to_owned(),
9383                message,
9384                retryable: false,
9385                details: None,
9386                operation: commit.ops.first().map(CommitOperation::from),
9387            };
9388            self.persist_commit_outcome(
9389                &commit.client_commit_id,
9390                CommitOutcomeStatus::Rejected,
9391                &[CommitOperationOutcome::Error {
9392                    rejection: rejection.clone(),
9393                }],
9394                Some(&commit.ops),
9395            )?;
9396            self.delete_outbox_persisted(&commit.client_commit_id)?;
9397            self.outbox
9398                .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
9399            self.rejections.push(rejection);
9400        }
9401        self.prune_commit_outcomes()?;
9402        self.rebuild_overlay_if_dirty();
9403        Ok(true)
9404    }
9405
9406    pub fn pending_commit_ids(&self) -> Vec<String> {
9407        self.outbox
9408            .iter()
9409            .map(|c| c.client_commit_id.clone())
9410            .collect()
9411    }
9412
9413    /// §6.1: the sparse push payload bytes of every pending upsert operation,
9414    /// FIFO — exactly what the next push round would put on the wire. Used by
9415    /// conformance B.21(h) to pin cross-core byte identity.
9416    pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
9417        let mut payloads = Vec::new();
9418        for commit in &self.outbox {
9419            for op in &commit.ops {
9420                let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
9421                    continue;
9422                };
9423                let Some(table) = self.schema.table(&op.table) else {
9424                    continue;
9425                };
9426                let fallback = self.stored_key_fallback(table, &op.row_id);
9427                if let Ok(payload) = encode_sparse_row_json(
9428                    table,
9429                    &op.row_id,
9430                    values,
9431                    &self.encryption,
9432                    fallback.as_ref(),
9433                ) {
9434                    payloads.push(payload);
9435                }
9436            }
9437        }
9438        payloads
9439    }
9440
9441    pub fn conflicts(&self) -> &[ConflictRecord] {
9442        &self.conflicts
9443    }
9444
9445    pub fn rejections(&self) -> &[RejectionRecord] {
9446        &self.rejections
9447    }
9448
9449    pub fn resolve_commit_outcome(
9450        &mut self,
9451        input: ResolveCommitOutcomeInput,
9452    ) -> Result<CommitOutcome, String> {
9453        let current = self
9454            .commit_outcome(&input.client_commit_id)?
9455            .ok_or_else(|| {
9456                format!(
9457                    "sync.outcome_not_found: no durable outcome exists for {:?}",
9458                    input.client_commit_id
9459                )
9460            })?;
9461        if current.resolution != CommitOutcomeResolution::Active {
9462            return Ok(current);
9463        }
9464        if input.resolution == CommitOutcomeResolution::Active {
9465            return Err("sync.invalid_request: resolution must leave active state".to_owned());
9466        }
9467        match input.resolution {
9468            CommitOutcomeResolution::Superseded => {
9469                let replacement = input
9470                    .replacement_client_commit_id
9471                    .as_deref()
9472                    .filter(|value| !value.is_empty() && *value != input.client_commit_id)
9473                    .ok_or_else(|| {
9474                        "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
9475                            .to_owned()
9476                    })?;
9477                let _ = replacement;
9478            }
9479            _ if input.replacement_client_commit_id.is_some() => {
9480                return Err(
9481                    "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
9482                        .to_owned(),
9483                );
9484            }
9485            _ => {}
9486        }
9487        let allowed = match current.status {
9488            CommitOutcomeStatus::Conflict => matches!(
9489                input.resolution,
9490                CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
9491            ),
9492            CommitOutcomeStatus::Rejected => {
9493                matches!(
9494                    input.resolution,
9495                    CommitOutcomeResolution::Superseded
9496                        | CommitOutcomeResolution::ResolvedKeepServer
9497                )
9498            }
9499            CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
9500                input.resolution == CommitOutcomeResolution::Dismissed
9501            }
9502        };
9503        if !allowed {
9504            return Err(format!(
9505                "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
9506                input.resolution, current.status
9507            ));
9508        }
9509
9510        self.begin_observation("syncular_outcome_resolution")?;
9511        let previous_failed = self.failed_commits.clone();
9512        let previous_conflicts = self.conflicts.clone();
9513        let previous_rejections = self.rejections.clone();
9514        let result = (|| {
9515            self.conn
9516                .execute(
9517                    "UPDATE _syncular_commit_outcomes
9518                        SET resolution = ?1, resolved_at_ms = ?2,
9519                            replacement_client_commit_id = ?3
9520                      WHERE client_commit_id = ?4 AND resolution = 'active'",
9521                    rusqlite::params![
9522                        Self::outcome_resolution_name(input.resolution),
9523                        self.clock_now_ms(),
9524                        input.replacement_client_commit_id,
9525                        input.client_commit_id
9526                    ],
9527                )
9528                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9529            let resolved = self
9530                .commit_outcome(&current.client_commit_id)?
9531                .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
9532            self.conn
9533                .execute(
9534                    "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
9535                    rusqlite::params![input.client_commit_id],
9536                )
9537                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9538            for (commit, _) in &self.failed_commits {
9539                if commit.client_commit_id == input.client_commit_id {
9540                    for operation in &commit.ops {
9541                        self.overlay_dirty.table(&operation.table);
9542                    }
9543                }
9544            }
9545            self.failed_commits
9546                .retain(|(commit, _)| commit.client_commit_id != input.client_commit_id);
9547            self.prune_commit_outcomes()?;
9548            Ok(resolved)
9549        })();
9550        let resolved = match result {
9551            Ok(outcome) => outcome,
9552            Err(error) => {
9553                self.rollback_observation("syncular_outcome_resolution");
9554                self.failed_commits = previous_failed;
9555                self.conflicts = previous_conflicts;
9556                self.rejections = previous_rejections;
9557                return Err(error);
9558            }
9559        };
9560        self.conflicts
9561            .retain(|record| record.client_commit_id != current.client_commit_id);
9562        self.rejections
9563            .retain(|record| record.client_commit_id != current.client_commit_id);
9564        let mut batch = ChangeAccumulator {
9565            conflicts: current.status == CommitOutcomeStatus::Conflict,
9566            rejections: current.status == CommitOutcomeStatus::Rejected,
9567            outcomes: true,
9568            ..ChangeAccumulator::default()
9569        };
9570        if let Some(operations) = &current.operations {
9571            for operation in operations {
9572                batch.table(&operation.table);
9573            }
9574        }
9575        self.rebuild_overlay_if_dirty();
9576        if let Err(error) = self.finish_observation("syncular_outcome_resolution", batch) {
9577            self.rollback_observation("syncular_outcome_resolution");
9578            self.failed_commits = previous_failed;
9579            self.conflicts = previous_conflicts;
9580            self.rejections = previous_rejections;
9581            return Err(error);
9582        }
9583        Ok(resolved)
9584    }
9585
9586    pub fn schema_floor(&self) -> Option<&SchemaFloor> {
9587        self.schema_floor.as_ref()
9588    }
9589
9590    /// §7.3.5: the client's opaque auth-lease state, if any.
9591    pub fn lease_state(&self) -> Option<&LeaseState> {
9592        self.lease_state.as_ref()
9593    }
9594
9595    /// §7.3.5: record a request-level lease error (stop-and-surface). Only
9596    /// the two lease codes set it; other errors leave leaseState untouched.
9597    fn record_lease_error(&mut self, code: &str) {
9598        if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
9599            return;
9600        }
9601        let mut next = self.lease_state.clone().unwrap_or_default();
9602        next.error_code = Some(code.to_owned());
9603        self.set_lease_state(Some(next));
9604    }
9605
9606    fn set_lease_state(&mut self, next: Option<LeaseState>) {
9607        if self.lease_state == next {
9608            return;
9609        }
9610        if self.begin_observation("syncular_lease").is_err() {
9611            return;
9612        }
9613        self.lease_state = next;
9614        if let Some(lease) = &self.lease_state {
9615            if let Ok(json) = serde_json::to_string(lease) {
9616                self.set_meta(LEASE_STATE_KEY, &json);
9617            }
9618        } else {
9619            self.delete_meta(LEASE_STATE_KEY);
9620        }
9621        let batch = ChangeAccumulator {
9622            status: true,
9623            ..ChangeAccumulator::default()
9624        };
9625        if self.finish_observation("syncular_lease", batch).is_err() {
9626            self.rollback_observation("syncular_lease");
9627        }
9628    }
9629
9630    fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
9631        if self.schema_floor == next {
9632            return;
9633        }
9634        if self.begin_observation("syncular_schema_floor").is_err() {
9635            return;
9636        }
9637        self.schema_floor = next;
9638        self.stopped = self.schema_floor.is_some();
9639        if let Some(floor) = &self.schema_floor {
9640            if let Ok(json) = serde_json::to_string(floor) {
9641                self.set_meta(SCHEMA_FLOOR_KEY, &json);
9642            }
9643        } else {
9644            self.delete_meta(SCHEMA_FLOOR_KEY);
9645        }
9646        let batch = ChangeAccumulator {
9647            status: true,
9648            ..ChangeAccumulator::default()
9649        };
9650        if self
9651            .finish_observation("syncular_schema_floor", batch)
9652            .is_err()
9653        {
9654            self.rollback_observation("syncular_schema_floor");
9655        }
9656    }
9657
9658    fn set_upgrading(&mut self, value: bool) {
9659        if self.upgrading == value {
9660            return;
9661        }
9662        if self.begin_observation("syncular_upgrading").is_err() {
9663            return;
9664        }
9665        self.upgrading = value;
9666        let batch = ChangeAccumulator {
9667            status: true,
9668            ..ChangeAccumulator::default()
9669        };
9670        if self
9671            .finish_observation("syncular_upgrading", batch)
9672            .is_err()
9673        {
9674            self.rollback_observation("syncular_upgrading");
9675        }
9676    }
9677
9678    pub fn sync_needed(&self) -> bool {
9679        self.sync_needed
9680    }
9681
9682    pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
9683        let sub = self.subs.iter().find(|s| s.id == id)?;
9684        Some(SubscriptionStateView {
9685            id: sub.id.clone(),
9686            table: sub.table.clone(),
9687            status: sub.state.name().to_owned(),
9688            cursor: sub.cursor,
9689            has_resume_token: sub.bootstrap_state.is_some(),
9690            effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
9691            reason_code: sub.reason_code.clone(),
9692        })
9693    }
9694
9695    pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
9696        let schema_table = self
9697            .schema
9698            .table(table)
9699            .ok_or_else(|| format!("unknown table {table:?}"))?;
9700        let sql = format!(
9701            "SELECT * FROM {} ORDER BY {} ASC",
9702            visible_table(table),
9703            quote_ident(&schema_table.primary_key)
9704        );
9705        let mut stmt = self
9706            .conn
9707            .prepare(&sql)
9708            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9709        let mut rows = stmt
9710            .query([])
9711            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9712        let mut out = Vec::new();
9713        while let Some(row) = rows
9714            .next()
9715            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
9716        {
9717            let mut values = Map::new();
9718            for (i, column) in schema_table.columns.iter().enumerate() {
9719                let value = row
9720                    .get_ref(i)
9721                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9722                values.insert(column.name.clone(), sql_ref_to_json(column, value));
9723            }
9724            let version: i64 = row
9725                .get(schema_table.columns.len())
9726                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
9727            let row_id = match values.get(&schema_table.primary_key) {
9728                Some(Value::String(s)) => s.clone(),
9729                Some(Value::Number(n)) => n.to_string(),
9730                Some(Value::Bool(b)) => b.to_string(),
9731                other => format!("{}", other.cloned().unwrap_or(Value::Null)),
9732            };
9733            out.push(RowState {
9734                row_id,
9735                version,
9736                values,
9737            });
9738        }
9739        Ok(out)
9740    }
9741
9742    // -- §5.10.5 native CRDT (the `crdt-yjs` feature) --------------------------
9743    //
9744    // The Rust face of the §5.10.4 client model: a local crdt edit loads the
9745    // current stored (server-merged ⊕ pending-overlay) column bytes, applies
9746    // the op with `yrs`, re-encodes the whole doc state, and records a
9747    // baseVersion-less sparse upsert carrying only the primary key and the
9748    // crdt column (§5.10.3, §6.6). No local merge — merging is server-side;
9749    // the overlay re-materializes the edit immediately (optimistic apply,
9750    // §7.1) and the server-merged bytes arrive on the next pull, idempotently.
9751    // Byte-compatible with `@syncular/crdt-yjs`.
9752
9753    /// The current stored value of a `crdt` column for one row — the visible
9754    /// (optimistic) bytes, or `None` when the row is absent or the column is
9755    /// NULL (the empty document, §5.10.1). Errors if the column is not a
9756    /// `crdt` column (guards the app against a typo'd column name).
9757    #[cfg(feature = "crdt-yjs")]
9758    fn crdt_column_bytes(
9759        &self,
9760        table: &str,
9761        row_id: &str,
9762        column: &str,
9763    ) -> Result<Option<Vec<u8>>, String> {
9764        let schema_table = self
9765            .schema
9766            .table(table)
9767            .ok_or_else(|| format!("unknown table {table:?}"))?;
9768        let col = schema_table
9769            .columns
9770            .iter()
9771            .find(|c| c.name == column)
9772            .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
9773        if col.ty != ColumnType::Crdt {
9774            return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
9775        }
9776        let sql = format!(
9777            "SELECT {} FROM {} WHERE {}",
9778            quote_ident(column),
9779            visible_table(table),
9780            row_id_predicate(schema_table)
9781        );
9782        let bytes: Option<Vec<u8>> = self
9783            .conn
9784            .prepare_cached(&sql)
9785            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
9786            .query_row(rusqlite::params![row_id], |row| {
9787                row.get::<_, Option<Vec<u8>>>(0)
9788            })
9789            .map_err(|e| match e {
9790                rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
9791                other => other.to_string(),
9792            })?;
9793        Ok(bytes)
9794    }
9795
9796    /// §5.10.4 materialize: the collaborative text of a `crdt` column, decoded
9797    /// from the stored bytes with `yrs` — `YjsColumn.text(name).toString()`.
9798    /// An absent row / NULL column is the empty document (empty string).
9799    #[cfg(feature = "crdt-yjs")]
9800    pub fn crdt_text(
9801        &self,
9802        table: &str,
9803        row_id: &str,
9804        column: &str,
9805        name: &str,
9806    ) -> Result<String, String> {
9807        let bytes = self
9808            .crdt_column_bytes(table, row_id, column)?
9809            .unwrap_or_default();
9810        crate::crdt::text(&bytes, name)
9811    }
9812
9813    /// §5.10.4 push-an-update: apply a text insert to a `crdt` column and
9814    /// record the resulting crdt-only sparse operation. Returns the enqueued
9815    /// `clientCommitId`.
9816    #[cfg(feature = "crdt-yjs")]
9817    pub fn crdt_insert_text(
9818        &mut self,
9819        table: &str,
9820        row_id: &str,
9821        column: &str,
9822        name: &str,
9823        index: u32,
9824        value: &str,
9825    ) -> Result<String, String> {
9826        let current = self
9827            .crdt_column_bytes(table, row_id, column)?
9828            .unwrap_or_default();
9829        let update = crate::crdt::insert_text(&current, name, index, value)?;
9830        self.crdt_push_update(table, row_id, column, &update)
9831    }
9832
9833    /// §5.10.4 push-an-update: apply a text delete to a `crdt` column and
9834    /// record the resulting crdt-only sparse operation. Returns the enqueued
9835    /// `clientCommitId`.
9836    #[cfg(feature = "crdt-yjs")]
9837    pub fn crdt_delete_text(
9838        &mut self,
9839        table: &str,
9840        row_id: &str,
9841        column: &str,
9842        name: &str,
9843        index: u32,
9844        len: u32,
9845    ) -> Result<String, String> {
9846        let current = self
9847            .crdt_column_bytes(table, row_id, column)?
9848            .unwrap_or_default();
9849        let update = crate::crdt::delete_text(&current, name, index, len)?;
9850        self.crdt_push_update(table, row_id, column, &update)
9851    }
9852
9853    /// §5.10.4 generic escape hatch: apply an arbitrary Yjs update onto a
9854    /// `crdt` column's current state and record the resulting crdt-only sparse
9855    /// operation. The app authored the update with its own `yrs` model.
9856    /// Returns the enqueued `clientCommitId`.
9857    #[cfg(feature = "crdt-yjs")]
9858    pub fn crdt_apply_update(
9859        &mut self,
9860        table: &str,
9861        row_id: &str,
9862        column: &str,
9863        update: &[u8],
9864    ) -> Result<String, String> {
9865        let current = self
9866            .crdt_column_bytes(table, row_id, column)?
9867            .unwrap_or_default();
9868        let next = crate::crdt::apply_update(&current, update)?;
9869        self.crdt_push_update(table, row_id, column, &next)
9870    }
9871
9872    /// Shared tail of the crdt edit methods: record a §6.1 sparse crdt-only
9873    /// upsert — the primary key plus the crdt column, no `baseVersion` — and
9874    /// enqueue it. The server merges the crdt column and leaves every other
9875    /// column untouched (§5.10.3), so a baseVersion-less crdt edit cannot
9876    /// clobber a concurrent edit to another column. A locally absent row
9877    /// records the same partial operation; the server answers per §6.2.
9878    #[cfg(feature = "crdt-yjs")]
9879    fn crdt_push_update(
9880        &mut self,
9881        table: &str,
9882        row_id: &str,
9883        column: &str,
9884        crdt_bytes: &[u8],
9885    ) -> Result<String, String> {
9886        let mut bytes_obj = Map::new();
9887        bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
9888        let mut partial = Map::new();
9889        partial.insert(column.to_owned(), Value::Object(bytes_obj));
9890        self.patch(table, row_id, partial, None)
9891    }
9892
9893    /// Run an arbitrary read-only SQL query against the local database and
9894    /// return each row as a `column-name → JSON value` map. This is the seam
9895    /// the React `useSyncQuery` live-query API needs (it takes app-authored
9896    /// SQL over the visible tables/views, not a fixed table read like
9897    /// [`read_rows`]).
9898    ///
9899    /// Bound `params` are the driver value forms: JSON strings/numbers/bools/
9900    /// null bind directly; a `{"$bytes": hex}` object binds as a BLOB — the
9901    /// same envelope the command surface uses everywhere else. Output BLOB
9902    /// columns come back as `{"$bytes": hex}` to round-trip cleanly.
9903    ///
9904    /// The result column typing is dynamic (SQLite's stored affinity), because
9905    /// arbitrary SQL can alias, join, and compute — there is no schema column
9906    /// to consult per output cell, unlike [`read_rows`].
9907    pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
9908        query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
9909    }
9910
9911    /// Repository benchmark and conformance access, present only with `bench-internals`.
9912    #[cfg(feature = "bench-internals")]
9913    #[doc(hidden)]
9914    pub fn benchmark_connection(&mut self) -> &mut Connection {
9915        &mut self.conn
9916    }
9917
9918    /// Private benchmark intervals; absent from ordinary builds and diagnostics.
9919    #[cfg(feature = "bench-internals")]
9920    #[doc(hidden)]
9921    pub fn benchmark_phases(
9922        &self,
9923        enabled: Option<bool>,
9924        reset: bool,
9925    ) -> Result<Option<Value>, String> {
9926        self.benchmark_phases.configure(enabled, reset)?;
9927        Ok(self.benchmark_phases.snapshot())
9928    }
9929
9930    /// Rows, coverage, and local revision from one SQLite read snapshot.
9931    /// §7.5 atomic snapshot read. An `owner` records a failure in
9932    /// `diagnostics_snapshot().query_failures` until its next successful read.
9933    pub fn query_snapshot(
9934        &mut self,
9935        sql: &str,
9936        params: &[QueryValue],
9937        coverage: &[WindowCoverage],
9938        owner: Option<QueryOwner<'_>>,
9939    ) -> Result<QuerySnapshot, String> {
9940        let result = snapshot_connection(&self.conn, sql, params, coverage);
9941        if let Some(owner) = owner {
9942            self.record_query_read(owner, result.as_ref().err());
9943        }
9944        result.map_err(|failure| failure.to_string())
9945    }
9946
9947    /// §7.6: record the outcome of one owned snapshot read, including reads a
9948    /// host ran on a separate connection. A success removes the owner's entry;
9949    /// an identical failure leaves it unchanged; a changed failure moves last.
9950    pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
9951        let position = self
9952            .query_failures
9953            .iter()
9954            .position(|entry| entry.id == owner.id);
9955        let Some(failure) = failure else {
9956            if let Some(index) = position {
9957                self.query_failures.remove(index);
9958            }
9959            return;
9960        };
9961        let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
9962            .into_iter()
9963            .map(str::to_owned)
9964            .collect();
9965        let code = failure.code.unwrap_or("client.query_failed").to_owned();
9966        if position.is_some_and(|index| {
9967            let entry = &self.query_failures[index];
9968            entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
9969        }) {
9970            return;
9971        }
9972        if let Some(index) = position {
9973            self.query_failures.remove(index);
9974        }
9975        self.query_failures.push(DiagnosticQueryFailure {
9976            id: owner.id.to_owned(),
9977            tables,
9978            code,
9979            sqlite_code: failure.sqlite_code,
9980            at_ms: self.clock_now_ms(),
9981        });
9982        if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
9983            self.query_failures.remove(0);
9984        }
9985    }
9986
9987    // -- request building ---------------------------------------------------------
9988
9989    fn build_request(&self, url_capable: bool) -> (Message, RequestMeta) {
9990        #[cfg(feature = "bench-internals")]
9991        let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
9992        let log_epoch = self.get_meta(LOG_EPOCH_KEY);
9993        let mut frames = vec![Frame::ReqHeader {
9994            client_id: self.client_id.clone(),
9995            schema_version: self.schema.version,
9996            log_epoch: log_epoch.clone(),
9997        }];
9998        let mut pushed_ids = Vec::new();
9999        let mut ops_in_request = 0usize;
10000        let mut deferred_commits = 0usize;
10001        #[cfg(feature = "bench-internals")]
10002        let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
10003        for (index, commit) in self
10004            .outbox
10005            .iter()
10006            .take(if log_epoch.is_some() {
10007                self.outbox.len()
10008            } else {
10009                0
10010            })
10011            .enumerate()
10012        {
10013            // §6.1 splitBatch: stop at the operation cap — commits apply in
10014            // order, so everything from the first non-fitting commit on is
10015            // deferred to the next round. A single over-cap commit still goes
10016            // alone (commits are atomic and cannot be split).
10017            if ops_in_request > 0 && ops_in_request + commit.ops.len() > PUSH_OPS_PER_REQUEST {
10018                deferred_commits = self.outbox.len() - index;
10019                break;
10020            }
10021            ops_in_request += commit.ops.len();
10022            let operations = commit
10023                .ops
10024                .iter()
10025                .map(|op| {
10026                    let payload = op.values.as_ref().and_then(|values| {
10027                        let table = self.schema.table(&op.table)?;
10028                        // §0: outbox entries encode at send time with the
10029                        // current codec (validated at mutate()). §6.1: the
10030                        // values map's keys are the sparse presence set.
10031                        // §5.11: encrypted columns are encrypted here, with
10032                        // an absent key-id selector read from the stored row.
10033                        // `sync_inner` runs `drop_unencodable_outbox` first,
10034                        // so an encrypt failure cannot reach this encode; the
10035                        // author seam validated every other failure.
10036                        let fallback = self.stored_key_fallback(table, &op.row_id);
10037                        encode_sparse_row_json(
10038                            table,
10039                            &op.row_id,
10040                            values,
10041                            &self.encryption,
10042                            fallback.as_ref(),
10043                        )
10044                        .ok()
10045                    });
10046                    ssp2::model::Operation {
10047                        table: op.table.clone(),
10048                        row_id: op.row_id.clone(),
10049                        op: if op.upsert { Op::Upsert } else { Op::Delete },
10050                        base_version: op.base_version,
10051                        payload,
10052                    }
10053                })
10054                .collect();
10055            frames.push(Frame::PushCommit {
10056                client_commit_id: commit.client_commit_id.clone(),
10057                operations,
10058            });
10059            pushed_ids.push(commit.client_commit_id.clone());
10060        }
10061        #[cfg(feature = "bench-internals")]
10062        drop(outbox_phase);
10063        // §4.2/§5.4: bit 3 is advertised iff the transport can fetch a
10064        // bare URL — capability negotiation, decided per transport.
10065        let accept = self.limits.accept.unwrap_or(if url_capable {
10066            DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
10067        } else {
10068            DEFAULT_ACCEPT
10069        });
10070        frames.push(Frame::PullHeader {
10071            limit_commits: self.limits.limit_commits.unwrap_or(0),
10072            limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
10073            max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
10074            accept,
10075        });
10076        let mut fresh = Vec::new();
10077        for sub in &self.subs {
10078            if sub.state != SubState::Active {
10079                continue;
10080            }
10081            let mut scopes = sub.requested.clone();
10082            sort_scope_map(&mut scopes);
10083            frames.push(Frame::Subscription {
10084                id: sub.id.clone(),
10085                table: sub.table.clone(),
10086                scopes,
10087                params: sub.params.clone().map(RawJson),
10088                cursor: sub.cursor,
10089                bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
10090            });
10091            fresh.push((
10092                sub.id.clone(),
10093                sub.cursor < 0 && sub.bootstrap_state.is_none(),
10094            ));
10095        }
10096        let message = Message {
10097            wire_version: WIRE_VERSION,
10098            msg_kind: MsgKind::Request,
10099            frames,
10100        };
10101        (
10102            message,
10103            RequestMeta {
10104                pushed_ids,
10105                fresh,
10106                accept,
10107                deferred_commits,
10108            },
10109        )
10110    }
10111
10112    // -- sync -------------------------------------------------------------------
10113
10114    /// Cloneable observation handle; no client lock is needed to read it.
10115    pub fn progress(&self) -> ProgressObserver {
10116        self.progress.clone()
10117    }
10118
10119    pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
10120        let prepared = match self.prepare_sync_round(transport.supports_url_fetch()) {
10121            Ok(prepared) => prepared,
10122            Err(outcome) => return *outcome,
10123        };
10124        let mut next = prepared;
10125        loop {
10126            match self.apply_sync_round(next.exchange(transport)) {
10127                crate::AppliedSyncRound::Continue(prepared) => next = prepared,
10128                crate::AppliedSyncRound::Complete {
10129                    outcome, controls, ..
10130                } => {
10131                    for text in controls {
10132                        let _ = transport.realtime_send(&text);
10133                    }
10134                    return outcome;
10135                }
10136            }
10137        }
10138    }
10139
10140    fn finish_sync_round(&mut self, started_at_ms: i64, mut outcome: SyncOutcome) -> SyncOutcome {
10141        if let SyncOutcome::Failed {
10142            error_code,
10143            message,
10144            details,
10145        } = &mut outcome
10146        {
10147            if let Some(failure) = self.storage_failure.borrow().as_ref() {
10148                let code = failure
10149                    .code
10150                    .expect("retained storage failures are classified");
10151                *error_code = code.to_owned();
10152                *message = match code {
10153                    "client.storage_full" => "local SQLite storage is full",
10154                    "client.storage_io" => "local SQLite storage I/O failed",
10155                    "client.storage_corrupt" => "local SQLite storage is corrupt",
10156                    _ => unreachable!("unrecognized classified storage failure"),
10157                }
10158                .to_owned();
10159                *details = failure.details();
10160            }
10161        }
10162        let retry_delay_ms = self.round_retry_delay_ms;
10163        self.progress.update(|p| match &outcome {
10164            SyncOutcome::Ok(report) if report.failed.is_empty() => {
10165                p.state = ProgressState::Complete
10166            }
10167            SyncOutcome::Ok(_) => {
10168                p.state = ProgressState::Failed;
10169                p.error_code = Some("sync.scope_revoked".into());
10170            }
10171            SyncOutcome::Failed { error_code, .. } => {
10172                p.state = ProgressState::Failed;
10173                p.error_code = Some(Self::diagnostic_code(error_code));
10174                p.retry_delay_ms = retry_delay_ms;
10175            }
10176            SyncOutcome::RealtimeUnavailable { .. } => {
10177                p.state = ProgressState::Failed;
10178                p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
10179                p.retry_delay_ms = retry_delay_ms;
10180            }
10181        });
10182        let completed_at_ms = self.clock_now_ms();
10183        self.last_round = Some(match &outcome {
10184            SyncOutcome::Ok(report) => DiagnosticLastRound {
10185                status: "succeeded".to_owned(),
10186                started_at_ms,
10187                completed_at_ms,
10188                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
10189                counters: Some(DiagnosticRoundCounters {
10190                    pushed: report.pushed,
10191                    applied: report.applied.len(),
10192                    rejected: report.rejected.len(),
10193                    retryable: report.retryable.len(),
10194                    conflicts: report.conflicts,
10195                    commits_applied: report.commits_applied,
10196                    segment_rows_applied: report.segment_rows_applied,
10197                    bootstrapping: report.bootstrapping.len(),
10198                    resets: report.resets.len(),
10199                    revoked: report.revoked.len(),
10200                    failed: report.failed.len(),
10201                    deferred_commits: report.deferred_commits,
10202                }),
10203                error_code: None,
10204            },
10205            SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
10206                status: "failed".to_owned(),
10207                started_at_ms,
10208                completed_at_ms,
10209                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
10210                counters: None,
10211                error_code: Some(Self::diagnostic_code(error_code)),
10212            },
10213            SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
10214                status: "failed".to_owned(),
10215                started_at_ms,
10216                completed_at_ms,
10217                duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
10218                counters: None,
10219                error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
10220            },
10221        });
10222        outcome
10223    }
10224
10225    /// Capture one request on the client owner. Mutations made after this
10226    /// call remain in the outbox for a later round; no network I/O occurs here.
10227    pub fn prepare_sync_round(
10228        &mut self,
10229        url_capable: bool,
10230    ) -> Result<crate::PreparedSyncRound, Box<SyncOutcome>> {
10231        if self.active_round.is_some() {
10232            return Err(Box::new(SyncOutcome::Failed {
10233                error_code: "client.round_in_progress".into(),
10234                message: "a sync round is already in flight".into(),
10235                details: None,
10236            }));
10237        }
10238        self.storage_failure.borrow_mut().take();
10239        self.progress.start();
10240        self.round_retry_delay_ms = None;
10241        let started_at_ms = self.clock_now_ms();
10242        let prepared = (|| {
10243            if self.stopped {
10244                return Err(Box::new(SyncOutcome::Ok(SyncReport {
10245                    schema_floor: self.schema_floor.clone(),
10246                    ..SyncReport::default()
10247                })));
10248            }
10249            if self.security_preflight {
10250                return Err(Box::new(SyncOutcome::Failed {
10251                    error_code: SECURITY_PREFLIGHT_REQUIRED_CODE.into(),
10252                    message: "security preflight must complete before syncing".into(),
10253                    details: None,
10254                }));
10255            }
10256            self.set_sync_needed(false, false);
10257            let uploads = if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
10258                self.pending_blob_uploads().map_err(|e| {
10259                    Box::new(SyncOutcome::Failed {
10260                        error_code: e.code,
10261                        message: e.message,
10262                        details: e.details,
10263                    })
10264                })?
10265            } else {
10266                Vec::new()
10267            };
10268            self.drain_pending_evictions()
10269                .and_then(|_| self.drop_unencodable_outbox())
10270                .map_err(|message| {
10271                    Box::new(SyncOutcome::Failed {
10272                        error_code: "storage.failed".into(),
10273                        message,
10274                        details: None,
10275                    })
10276                })?;
10277            if self.realtime_state != RealtimeState::Connected
10278                && self.realtime_policy == RealtimePolicy::Required
10279            {
10280                let retry_delay_ms = self.schedule_background_retry();
10281                self.realtime_retry_delay_ms = Some(retry_delay_ms);
10282                return Err(Box::new(SyncOutcome::RealtimeUnavailable {
10283                    state: self.realtime_state,
10284                    reason_code: self.realtime_reason_code.clone(),
10285                    retry_delay_ms,
10286                }));
10287            }
10288            let (message, meta) = self.build_request(url_capable);
10289            let id = uuid::Uuid::new_v4();
10290            self.active_round = Some(id);
10291            Ok(crate::PreparedSyncRound {
10292                id,
10293                started_at_ms,
10294                message,
10295                meta,
10296                uploads: uploads.into(),
10297                realtime: self.realtime_state == RealtimeState::Connected,
10298                scopes: self
10299                    .subs
10300                    .iter()
10301                    .map(|s| (s.id.clone(), canonical_scope_json(&s.requested)))
10302                    .collect(),
10303                #[cfg(feature = "bench-internals")]
10304                benchmark_phases: self.benchmark_phases.clone(),
10305                progress: self.progress.clone(),
10306                fixed_now: self.now_ms,
10307            })
10308        })();
10309        prepared.map_err(|outcome| Box::new(self.finish_sync_round(started_at_ms, *outcome)))
10310    }
10311
10312    /// Discard the reply of an old authorization/subscription context. Its
10313    /// submitted commits stay durable and can be retried with the same ids.
10314    pub fn cancel_sync_round(&mut self) {
10315        self.active_round = None;
10316    }
10317
10318    /// Apply captured network results on the owner, preserving all normal
10319    /// version, schema, content-address and cursor checks. No network I/O.
10320    pub fn apply_sync_round(
10321        &mut self,
10322        completed: crate::CompletedSyncRound,
10323    ) -> crate::AppliedSyncRound {
10324        let prepared = completed.prepared;
10325        if self.active_round != Some(prepared.id) || self.security_preflight {
10326            return crate::AppliedSyncRound::Complete {
10327                outcome: SyncOutcome::Failed {
10328                    error_code: "client.round_cancelled".into(),
10329                    message: "sync round belongs to a released client context".into(),
10330                    details: None,
10331                },
10332                controls: Vec::new(),
10333                more: false,
10334                bootstrap_advanced: false,
10335            };
10336        }
10337        self.storage_failure.borrow_mut().take();
10338        let (response, mut downloads, transport_failed) = match completed.exchange {
10339            crate::round::ExchangeResult::Upload { id, result } => {
10340                let result = result.and_then(|()| {
10341                    self.conn
10342                        .execute(
10343                            "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
10344                            rusqlite::params![id],
10345                        )
10346                        .map(|_| ())
10347                        .map_err(|error| {
10348                            TransportError::new(
10349                                "client.failed",
10350                                Self::sqlite_failure(&self.storage_failure, error),
10351                            )
10352                        })
10353                });
10354                match result {
10355                    Ok(()) => return crate::AppliedSyncRound::Continue(prepared),
10356                    Err(TransportError {
10357                        code,
10358                        message,
10359                        details,
10360                    }) => {
10361                        self.active_round = None;
10362                        if Self::retryable_failure_code(&code) {
10363                            self.schedule_background_retry();
10364                        }
10365                        let outcome = self.finish_sync_round(
10366                            prepared.started_at_ms,
10367                            SyncOutcome::Failed {
10368                                error_code: code,
10369                                message,
10370                                details,
10371                            },
10372                        );
10373                        return crate::AppliedSyncRound::Complete {
10374                            outcome,
10375                            controls: Vec::new(),
10376                            more: false,
10377                            bootstrap_advanced: false,
10378                        };
10379                    }
10380                }
10381            }
10382            crate::round::ExchangeResult::Reply {
10383                response,
10384                downloads,
10385                transport_failed,
10386            } => (response, downloads, transport_failed),
10387        };
10388        self.active_round = None;
10389        let before = self
10390            .subs
10391            .iter()
10392            .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
10393            .collect::<Vec<_>>();
10394        let outcome = (|| {
10395            let response = match response {
10396                Ok(response) => response,
10397                Err(TransportError {
10398                    code,
10399                    message,
10400                    details,
10401                }) => {
10402                    if transport_failed {
10403                        if prepared.realtime {
10404                            self.set_realtime_state(RealtimeState::Lost, Some(&code));
10405                        }
10406                        self.record_lease_error(&code);
10407                    }
10408                    if Self::retryable_failure_code(&code) {
10409                        let delay = self.schedule_background_retry();
10410                        if prepared.realtime && transport_failed {
10411                            self.realtime_retry_delay_ms = Some(delay);
10412                        }
10413                    }
10414                    return SyncOutcome::Failed {
10415                        error_code: code,
10416                        message,
10417                        details,
10418                    };
10419                }
10420            };
10421            let mut outcome = self.process_response(&mut downloads, response, &prepared.meta);
10422            if let SyncOutcome::Ok(report) = &mut outcome {
10423                report.deferred_commits = prepared.meta.deferred_commits;
10424            }
10425            match &outcome {
10426                SyncOutcome::Ok(_) => self.reset_background_retry(),
10427                SyncOutcome::Failed { error_code, .. }
10428                    if Self::retryable_failure_code(error_code) =>
10429                {
10430                    self.schedule_background_retry();
10431                }
10432                _ => {}
10433            }
10434            if prepared.meta.deferred_commits > 0 {
10435                self.set_sync_needed(true, true);
10436            }
10437            self.previous_version_lifetime_check();
10438            outcome
10439        })();
10440        let more = matches!(&outcome, SyncOutcome::Ok(report) if !report.bootstrapping.is_empty() || report.commits_applied > 0 || report.segment_rows_applied > 0 || !report.resets.is_empty() || self.sync_needed);
10441        let bootstrap_advanced = matches!(&outcome, SyncOutcome::Ok(report) if report.segment_rows_applied > 0 && !report.bootstrapping.is_empty())
10442            && before
10443                != self
10444                    .subs
10445                    .iter()
10446                    .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
10447                    .collect::<Vec<_>>();
10448        crate::AppliedSyncRound::Complete {
10449            outcome: self.finish_sync_round(prepared.started_at_ms, outcome),
10450            controls: downloads.controls,
10451            more,
10452            bootstrap_advanced,
10453        }
10454    }
10455
10456    pub fn sync_until_idle(
10457        &mut self,
10458        transport: &mut dyn Transport,
10459        max_rounds: Option<u32>,
10460    ) -> SyncOutcome {
10461        let rounds = max_rounds.unwrap_or(20).max(1);
10462        let mut aggregate = SyncReport::default();
10463        let mut spent = 0;
10464        while spent < rounds {
10465            spent += 1;
10466            let before = self
10467                .subs
10468                .iter()
10469                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
10470                .collect::<Vec<_>>();
10471            let outcome = self.sync(transport);
10472            match outcome {
10473                SyncOutcome::Failed {
10474                    error_code,
10475                    message,
10476                    details,
10477                } => {
10478                    return SyncOutcome::Failed {
10479                        details,
10480                        error_code,
10481                        message,
10482                    };
10483                }
10484                SyncOutcome::RealtimeUnavailable { .. } => return outcome,
10485                SyncOutcome::Ok(report) => {
10486                    if max_rounds.is_none()
10487                        && report.segment_rows_applied > 0
10488                        && !report.bootstrapping.is_empty()
10489                        && before
10490                            != self
10491                                .subs
10492                                .iter()
10493                                .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
10494                                .collect::<Vec<_>>()
10495                    {
10496                        spent = 0;
10497                    }
10498                    aggregate.merge(&report);
10499                    // §4.5: pull again whenever the response contained
10500                    // commits or segments; resets re-bootstrap; a pending
10501                    // resume token continues paging (§4.7); a raised
10502                    // sync-needed signal covers §6.1 splitBatch remainders
10503                    // (deferred outbox commits push on the next round).
10504                    let more = !report.bootstrapping.is_empty()
10505                        || report.commits_applied > 0
10506                        || report.segment_rows_applied > 0
10507                        || !report.resets.is_empty()
10508                        || self.sync_needed;
10509                    if !more {
10510                        return SyncOutcome::Ok(aggregate);
10511                    }
10512                }
10513            }
10514        }
10515        SyncOutcome::Failed {
10516            details: None,
10517            error_code: "sync.invalid_request".into(),
10518            message: "sync did not reach idle within the round budget".into(),
10519        }
10520    }
10521
10522    fn process_response(
10523        &mut self,
10524        transport: &mut dyn Transport,
10525        response: Message,
10526        meta: &RequestMeta,
10527    ) -> SyncOutcome {
10528        #[cfg(feature = "bench-internals")]
10529        let _phase = self.benchmark_phases.start(Phase::ResponseApply);
10530        let mut report = SyncReport {
10531            pushed: meta.pushed_ids.len() as u32,
10532            ..SyncReport::default()
10533        };
10534        let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
10535            HashMap::new();
10536        let pushed_ids = meta
10537            .pushed_ids
10538            .iter()
10539            .map(String::as_str)
10540            .collect::<HashSet<_>>();
10541        let mut last_final_push_result_id: Option<String> = None;
10542        for frame in &response.frames {
10543            match frame {
10544                Frame::PushResultDetails {
10545                    client_commit_id,
10546                    entries,
10547                } => {
10548                    let details = rejection_details_by_commit
10549                        .entry(client_commit_id.clone())
10550                        .or_default();
10551                    for entry in entries {
10552                        let parsed = match RejectionDetails::parse(&entry.details.0) {
10553                            Ok(value) => value,
10554                            Err(message) => {
10555                                return SyncOutcome::Failed {
10556                                    details: None,
10557                                    error_code: "sync.invalid_request".to_owned(),
10558                                    message,
10559                                };
10560                            }
10561                        };
10562                        details.insert(entry.op_index, parsed);
10563                    }
10564                }
10565                Frame::PushResult {
10566                    client_commit_id,
10567                    status,
10568                    results,
10569                    ..
10570                } if pushed_ids.contains(client_commit_id.as_str())
10571                    && Self::push_result_is_final(*status, results) =>
10572                {
10573                    last_final_push_result_id = Some(client_commit_id.clone());
10574                }
10575                _ => {}
10576            }
10577        }
10578        let mut frames = response.frames.into_iter().peekable();
10579        if response.wire_version < 2 {
10580            return SyncOutcome::Failed {
10581                details: None,
10582                error_code: "client.invalid_host_response".to_owned(),
10583                message: "server response does not carry wire version 2 log-epoch state".to_owned(),
10584            };
10585        }
10586        match frames.next() {
10587            Some(Frame::RespHeader {
10588                required_schema_version,
10589                latest_schema_version,
10590                log_epoch,
10591                reset_required,
10592            }) => {
10593                let Some(log_epoch) = log_epoch else {
10594                    return SyncOutcome::Failed {
10595                        details: None,
10596                        error_code: "client.invalid_host_response".to_owned(),
10597                        message: "response header omits logEpoch".to_owned(),
10598                    };
10599                };
10600                let Some(reset_required) = reset_required else {
10601                    return SyncOutcome::Failed {
10602                        details: None,
10603                        error_code: "client.invalid_host_response".to_owned(),
10604                        message: "response header omits resetRequired".to_owned(),
10605                    };
10606                };
10607                if let Some(required) = required_schema_version {
10608                    // §1.6 schema-floor response: nothing else is processed;
10609                    // stop syncing and surface the upgrade requirement.
10610                    let floor = SchemaFloor {
10611                        required_schema_version: Some(required),
10612                        latest_schema_version,
10613                    };
10614                    self.set_schema_floor(Some(floor.clone()));
10615                    report.schema_floor = Some(floor);
10616                    return SyncOutcome::Ok(report);
10617                }
10618                if reset_required {
10619                    if frames.next().is_some() {
10620                        return SyncOutcome::Failed {
10621                            details: None,
10622                            error_code: "client.invalid_host_response".to_owned(),
10623                            message: "log-epoch reset response contains body frames".to_owned(),
10624                        };
10625                    }
10626                    match self.run_log_epoch_reset(&log_epoch) {
10627                        Ok(resets) => {
10628                            report.resets = resets;
10629                            return SyncOutcome::Ok(report);
10630                        }
10631                        Err(message) => {
10632                            return SyncOutcome::Failed {
10633                                details: None,
10634                                error_code: "sync.local_corrupt".to_owned(),
10635                                message,
10636                            };
10637                        }
10638                    }
10639                }
10640                if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
10641                    return SyncOutcome::Failed {
10642                        details: None,
10643                        error_code: "client.invalid_host_response".to_owned(),
10644                        message: "server changed logEpoch without requiring a reset".to_owned(),
10645                    };
10646                }
10647            }
10648            _ => {
10649                return SyncOutcome::Failed {
10650                    details: None,
10651                    error_code: "sync.invalid_request".to_owned(),
10652                    message: "response does not start with RESP_HEADER".to_owned(),
10653                };
10654            }
10655        }
10656
10657        let mut failure: Option<(String, String)> = None;
10658        while let Some(frame) = frames.next() {
10659            match frame {
10660                frame @ Frame::PushResult { .. } => {
10661                    let successful = matches!(
10662                        &frame,
10663                        Frame::PushResult {
10664                            status: PushStatus::Applied | PushStatus::Cached,
10665                            ..
10666                        }
10667                    );
10668                    let mut results = vec![frame];
10669                    if successful {
10670                        while matches!(
10671                            frames.peek(),
10672                            Some(Frame::PushResult {
10673                                status: PushStatus::Applied | PushStatus::Cached,
10674                                ..
10675                            })
10676                        ) {
10677                            if let Some(next) = frames.next() {
10678                                results.push(next);
10679                            }
10680                        }
10681                    }
10682                    if self
10683                        .handle_push_results(
10684                            &results,
10685                            &pushed_ids,
10686                            &rejection_details_by_commit,
10687                            &mut report,
10688                            last_final_push_result_id.as_deref(),
10689                        )
10690                        .is_err()
10691                    {
10692                        failure = Some((
10693                            "client.outcome_persistence_failed".to_owned(),
10694                            "local commit outcome could not be persisted".to_owned(),
10695                        ));
10696                        break;
10697                    }
10698                }
10699                Frame::PushResultDetails { .. } => {}
10700                Frame::SubStart {
10701                    id,
10702                    status,
10703                    reason_code,
10704                    effective_scopes,
10705                    bootstrap: _,
10706                } => {
10707                    let mut body = Vec::new();
10708                    let mut sub_end: Option<(i64, Option<String>)> = None;
10709                    for inner in frames.by_ref() {
10710                        match inner {
10711                            Frame::SubEnd {
10712                                next_cursor,
10713                                bootstrap_state,
10714                            } => {
10715                                sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
10716                                break;
10717                            }
10718                            Frame::Unknown { .. } => {}
10719                            other => body.push(other),
10720                        }
10721                    }
10722                    if let Err(SectionError::Abort(code, message)) = self.process_section(
10723                        transport,
10724                        &id,
10725                        status,
10726                        &reason_code,
10727                        effective_scopes,
10728                        body,
10729                        sub_end,
10730                        meta,
10731                        &mut report,
10732                    ) {
10733                        failure = Some((code, message));
10734                        break;
10735                    }
10736                }
10737                Frame::Lease {
10738                    lease_id,
10739                    expires_at_ms,
10740                } => {
10741                    // §7.3.5: persist the opaque lease; a fresh lease clears
10742                    // any prior lease error (the outage/revocation is over).
10743                    self.set_lease_state(Some(LeaseState {
10744                        lease_id: Some(lease_id),
10745                        expires_at_ms: Some(expires_at_ms),
10746                        error_code: None,
10747                    }));
10748                }
10749                Frame::Error { code, message, .. } => {
10750                    // §1.6: the whole request failed; already-completed
10751                    // subscriptions keep their applied data and cursors.
10752                    failure = Some((code, message));
10753                    break;
10754                }
10755                Frame::Unknown { .. } => {}
10756                _ => {
10757                    failure = Some((
10758                        "sync.invalid_request".to_owned(),
10759                        "unexpected frame in response".to_owned(),
10760                    ));
10761                    break;
10762                }
10763            }
10764        }
10765
10766        // §7.1: reconcile the visible overlay once at the response boundary
10767        // after all outbox acknowledgements and server data applied so a
10768        // batch of PUSH_RESULT frames cannot trigger repeated full-table
10769        // rebuilds. This also covers a round that aborted mid-way.
10770        if self.overlay_dirty.get() {
10771            self.rebuild_overlay();
10772        }
10773        if let Some((error_code, message)) = failure {
10774            return SyncOutcome::Failed {
10775                details: None,
10776                error_code,
10777                message,
10778            };
10779        }
10780        // §4.8 E1: the push half may have drained commits that pinned rows of
10781        // a shrunk window unit — retry any deferred evictions now.
10782        if let Err(message) = self.drain_pending_evictions() {
10783            return SyncOutcome::Failed {
10784                details: None,
10785                error_code: "storage.failed".into(),
10786                message,
10787            };
10788        }
10789        self.ack_after_pull(transport);
10790        // §7.4.5: the reset is over once the first post-reset pull round
10791        // leaves no subscription mid-bootstrap — the tables are rebuilt.
10792        if self.upgrading && report.bootstrapping.is_empty() {
10793            self.set_upgrading(false);
10794        }
10795        SyncOutcome::Ok(report)
10796    }
10797
10798    // -- push results (§6.3, §7.2) ------------------------------------------------
10799
10800    fn handle_push_results(
10801        &mut self,
10802        frames: &[Frame],
10803        pushed_ids: &HashSet<&str>,
10804        rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
10805        report: &mut SyncReport,
10806        last_final_push_result_id: Option<&str>,
10807    ) -> Result<(), String> {
10808        self.begin_observation("syncular_push_results")?;
10809        let previous_failed = self.failed_commits.clone();
10810        let previous_sync_needed = self.sync_needed;
10811        let conflict_count = self.conflicts.len();
10812        let rejection_count = self.rejections.len();
10813        let applied_count = report.applied.len();
10814        let rejected_count = report.rejected.len();
10815        let retryable_count = report.retryable.len();
10816        let was_dirty = self.overlay_dirty.snapshot();
10817        let mut removed = Vec::new();
10818        let mut batch = ChangeAccumulator::default();
10819        let persisted = (|| {
10820            for frame in frames {
10821                let Frame::PushResult {
10822                    client_commit_id,
10823                    status,
10824                    results,
10825                    commit_seq,
10826                } = frame
10827                else {
10828                    return Err("unexpected acknowledgement frame".to_owned());
10829                };
10830                if !pushed_ids.contains(client_commit_id.as_str()) {
10831                    continue;
10832                }
10833                let Some(index) = self
10834                    .outbox
10835                    .iter()
10836                    .position(|commit| commit.client_commit_id == *client_commit_id)
10837                else {
10838                    continue;
10839                };
10840                let status = *status;
10841                let rejection_details = rejection_details_by_commit.get(client_commit_id);
10842                let operations = self.outbox[index].ops.clone();
10843                match status {
10844                    PushStatus::Applied | PushStatus::Cached => {
10845                        // §7.2: a lost ack replays as `cached` — proceed as if the
10846                        // ack had arrived.
10847                        let sequence =
10848                            commit_seq.ok_or_else(|| "ACK lacks a commit sequence".to_owned())?;
10849                        let prior = self.acknowledged_operations(None)?;
10850                        for (op_index, operation) in operations.iter().enumerate() {
10851                            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))
10852                                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10853                            if delivered {
10854                                continue;
10855                            }
10856                            let table = self
10857                                .schema
10858                                .table(&operation.table)
10859                                .ok_or_else(|| "sync.unknown_table".to_owned())?;
10860                            let mut intended: Map<String, Value> = self
10861                                .stored_row(table, &operation.row_id, true)
10862                                .map(|(row, _)| {
10863                                    table
10864                                        .columns
10865                                        .iter()
10866                                        .zip(&row)
10867                                        .map(|(column, value)| {
10868                                            (column.name.clone(), column_value_to_json(value))
10869                                        })
10870                                        .collect()
10871                                })
10872                                .unwrap_or_default();
10873                            let mut scope_image = intended.clone();
10874                            for op in prior
10875                                .iter()
10876                                .chain(
10877                                    self.failed_commits
10878                                        .iter()
10879                                        .flat_map(|(commit, _)| &commit.ops),
10880                                )
10881                                .chain(
10882                                    self.outbox
10883                                        .iter()
10884                                        .take(index)
10885                                        .flat_map(|commit| &commit.ops),
10886                                )
10887                                .chain(operations.iter().take(op_index + 1))
10888                            {
10889                                if op.table != operation.table || op.row_id != operation.row_id {
10890                                    continue;
10891                                }
10892                                if let Some(values) = &op.values {
10893                                    intended.extend(values.clone());
10894                                } else {
10895                                    scope_image = intended.clone();
10896                                    intended.clear();
10897                                }
10898                            }
10899                            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)",
10900                                rusqlite::params![client_commit_id, op_index as i64, operation.table, operation.row_id, sequence,
10901                                    serde_json::to_string(&CommitOperation::from(operation)).map_err(|_| "sync.local_corrupt".to_owned())?,
10902                                    serde_json::to_string(if operation.upsert { &intended } else { &scope_image }).map_err(|_| "sync.local_corrupt".to_owned())?])
10903                                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10904                        }
10905                        let journal_results = results
10906                            .iter()
10907                            .map(|result| {
10908                                let op_index = match result {
10909                                    OpResult::Applied { op_index }
10910                                    | OpResult::Conflict { op_index, .. }
10911                                    | OpResult::Error { op_index, .. } => *op_index,
10912                                };
10913                                CommitOperationOutcome::Applied { op_index }
10914                            })
10915                            .collect::<Vec<_>>();
10916                        let outcome_status = if status == PushStatus::Applied {
10917                            CommitOutcomeStatus::Applied
10918                        } else {
10919                            CommitOutcomeStatus::Cached
10920                        };
10921                        let persisted = self
10922                            .persist_commit_outcome(
10923                                client_commit_id,
10924                                outcome_status,
10925                                &journal_results,
10926                                None,
10927                            )
10928                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
10929                        persisted?;
10930                        for operation in &operations {
10931                            self.overlay_dirty.table(&operation.table);
10932                        }
10933                        self.sync_needed = true;
10934                        batch.status = true;
10935                        batch.outcomes = true;
10936                    }
10937                    PushStatus::Rejected => {
10938                        if results.iter().any(|result| {
10939                            matches!(
10940                                result,
10941                                OpResult::Error {
10942                                    code,
10943                                    retryable: true,
10944                                    ..
10945                                } if code == "sync.idempotency_cache_miss"
10946                            )
10947                        }) {
10948                            // §6.3/§7.2: a serving failure, not an outcome — keep the
10949                            // exact commit queued for an identical retry.
10950                            report.retryable.push(client_commit_id.to_owned());
10951                            continue;
10952                        }
10953
10954                        let mut journal_results = Vec::with_capacity(results.len());
10955                        let mut conflicts = Vec::new();
10956                        let mut rejections = Vec::new();
10957                        for result in results {
10958                            match result {
10959                                OpResult::Applied { op_index } => {
10960                                    journal_results.push(CommitOperationOutcome::Applied {
10961                                        op_index: *op_index,
10962                                    });
10963                                }
10964                                OpResult::Conflict {
10965                                    op_index,
10966                                    code,
10967                                    message,
10968                                    server_version,
10969                                    server_row,
10970                                    conflict_columns: conflict_columns_bitmap,
10971                                } => {
10972                                    let operation = operations
10973                                        .get(*op_index as usize)
10974                                        .map(CommitOperation::from);
10975                                    let (table, row_id) = operation
10976                                        .as_ref()
10977                                        .map(|op| (op.table.clone(), op.row_id.clone()))
10978                                        .unwrap_or_default();
10979                                    let server_row_json = self
10980                                        .schema
10981                                        .table(&table)
10982                                        .and_then(|t| {
10983                                            decode_row_bytes(t, server_row, &self.encryption)
10984                                                .ok()
10985                                                .map(|row| (t, row))
10986                                        })
10987                                        .map(|(t, row)| {
10988                                            let mut map = Map::new();
10989                                            for (i, column) in t.columns.iter().enumerate() {
10990                                                map.insert(
10991                                                    column.name.clone(),
10992                                                    column_value_to_json(
10993                                                        row.get(i).unwrap_or(&None),
10994                                                    ),
10995                                                );
10996                                            }
10997                                            map
10998                                        })
10999                                        .unwrap_or_default();
11000                                    // §6.3: decode the presence-layout bitmap
11001                                    // into the marked column names.
11002                                    let conflict_columns = self
11003                                        .schema
11004                                        .table(&table)
11005                                        .map(|t| {
11006                                            t.columns
11007                                                .iter()
11008                                                .enumerate()
11009                                                .filter(|(index, _)| {
11010                                                    conflict_columns_bitmap
11011                                                        .get(index / 8)
11012                                                        .is_some_and(|byte| {
11013                                                            byte & (1 << (index % 8)) != 0
11014                                                        })
11015                                                })
11016                                                .map(|(_, column)| column.name.clone())
11017                                                .collect::<Vec<_>>()
11018                                        })
11019                                        .unwrap_or_default();
11020                                    let conflict = ConflictRecord {
11021                                        client_commit_id: client_commit_id.to_owned(),
11022                                        op_index: *op_index,
11023                                        table,
11024                                        row_id,
11025                                        code: code.clone(),
11026                                        message: message.clone(),
11027                                        server_version: *server_version,
11028                                        server_row: server_row_json,
11029                                        conflict_columns,
11030                                        operation,
11031                                    };
11032                                    journal_results.push(CommitOperationOutcome::Conflict {
11033                                        conflict: conflict.clone(),
11034                                    });
11035                                    conflicts.push(conflict);
11036                                }
11037                                OpResult::Error {
11038                                    op_index,
11039                                    code,
11040                                    message,
11041                                    retryable,
11042                                } => {
11043                                    let rejection = RejectionRecord {
11044                                        client_commit_id: client_commit_id.to_owned(),
11045                                        op_index: *op_index,
11046                                        code: code.clone(),
11047                                        message: message.clone(),
11048                                        retryable: *retryable,
11049                                        details: rejection_details
11050                                            .and_then(|details| details.get(op_index))
11051                                            .cloned(),
11052                                        operation: operations
11053                                            .get(*op_index as usize)
11054                                            .map(CommitOperation::from),
11055                                    };
11056                                    journal_results.push(CommitOperationOutcome::Error {
11057                                        rejection: rejection.clone(),
11058                                    });
11059                                    rejections.push(rejection);
11060                                }
11061                            }
11062                        }
11063                        if self.retain_failed_commits {
11064                            self.retain_failed_commit(client_commit_id, &operations)?;
11065                        }
11066                        let outcome_status = if conflicts.is_empty() {
11067                            CommitOutcomeStatus::Rejected
11068                        } else {
11069                            CommitOutcomeStatus::Conflict
11070                        };
11071                        let persisted = self
11072                            .persist_commit_outcome(
11073                                client_commit_id,
11074                                outcome_status,
11075                                &journal_results,
11076                                Some(&operations),
11077                            )
11078                            .and_then(|()| self.delete_outbox_persisted(client_commit_id));
11079                        persisted?;
11080                        batch.conflicts = !conflicts.is_empty();
11081                        batch.rejections = !rejections.is_empty();
11082                        batch.status = true;
11083                        batch.outcomes = true;
11084                        self.conflicts.extend(conflicts);
11085                        self.rejections.extend(rejections);
11086                        for operation in &operations {
11087                            self.overlay_dirty.table(&operation.table);
11088                        }
11089                    }
11090                }
11091                removed.push((index, self.outbox.remove(index)));
11092                if batch.status {
11093                    for operation in &operations {
11094                        if !self.record_row_scopes(
11095                            &mut batch,
11096                            &operation.table,
11097                            &operation.row_id,
11098                            false,
11099                        ) {
11100                            batch.table(&operation.table);
11101                        }
11102                    }
11103                }
11104                match status {
11105                    PushStatus::Applied | PushStatus::Cached => {
11106                        report.applied.push(client_commit_id.to_owned())
11107                    }
11108                    PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
11109                }
11110            }
11111            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)) {
11112                self.prune_commit_outcomes()?;
11113            }
11114            self.finish_observation("syncular_push_results", batch)
11115        })();
11116        if let Err(error) = persisted {
11117            self.rollback_observation("syncular_push_results");
11118            self.failed_commits = previous_failed;
11119            self.sync_needed = previous_sync_needed;
11120            for (index, commit) in removed.into_iter().rev() {
11121                self.outbox.insert(index, commit);
11122            }
11123            self.conflicts.truncate(conflict_count);
11124            self.rejections.truncate(rejection_count);
11125            self.overlay_dirty.restore(was_dirty.clone());
11126            report.applied.truncate(applied_count);
11127            report.rejected.truncate(rejected_count);
11128            report.retryable.truncate(retryable_count);
11129            return Err(error);
11130        }
11131        if !previous_sync_needed && self.sync_needed {
11132            self.sync_intent_queue.push_back(SyncIntent::Interactive);
11133        }
11134        report.conflicts += (self.conflicts.len() - conflict_count) as u32;
11135        Ok(())
11136    }
11137
11138    fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
11139        status != PushStatus::Rejected
11140            || !results.iter().any(|result| {
11141                matches!(
11142                    result,
11143                    OpResult::Error {
11144                        code,
11145                        retryable: true,
11146                        ..
11147                    } if code == "sync.idempotency_cache_miss"
11148                )
11149            })
11150    }
11151
11152    // -- subscription sections ------------------------------------------------------
11153
11154    #[allow(clippy::too_many_arguments)]
11155    fn process_section(
11156        &mut self,
11157        transport: &mut dyn Transport,
11158        id: &str,
11159        status: SubStatus,
11160        reason_code: &str,
11161        effective_scopes: Vec<(String, Vec<String>)>,
11162        body: Vec<Frame>,
11163        sub_end: Option<(i64, Option<String>)>,
11164        meta: &RequestMeta,
11165        report: &mut SyncReport,
11166    ) -> Result<(), SectionError> {
11167        let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
11168            return Ok(()); // unknown echo: ignore
11169        };
11170        if status != SubStatus::Active && sub_end.is_none() {
11171            return Err(SectionError::Abort(
11172                "sync.invalid_request".into(),
11173                "subscription section without SUB_END".into(),
11174            ));
11175        }
11176        match status {
11177            SubStatus::Revoked => {
11178                self.begin_observation("syncular_revocation")
11179                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
11180                let previous = self.subs[sub_index].clone();
11181                let was_dirty = self.overlay_dirty.snapshot();
11182                let previous_outbox = self.outbox.clone();
11183                let previous_failed = self.failed_commits.clone();
11184                let previous_rejections = self.rejections.clone();
11185                let previous_conflicts = self.conflicts.clone();
11186                let revoked_count = report.revoked.len();
11187                let failed_count = report.failed.len();
11188                let result =
11189                    (|| {
11190                        let mut batch = ChangeAccumulator::default();
11191                        let registered = self.window_unit_by_sub_id(id);
11192                        // §3.3: stop pulling, purge exactly the last effective grant.
11193                        let (table, effective) = {
11194                            let sub = &self.subs[sub_index];
11195                            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
11196                        };
11197                        let scoped_table = self.schema.table(&table).cloned().ok_or_else(|| {
11198                            SectionError::Abort(
11199                                "sync.unknown_table".to_owned(),
11200                                "revoked scope references an unknown table".to_owned(),
11201                            )
11202                        })?;
11203                        let dropped = self
11204                            .drop_failed_commits(|name, values| {
11205                                name == table
11206                                    && !effective.is_empty()
11207                                    && effective.iter().all(|(variable, allowed)| {
11208                                        scoped_table
11209                                            .scope_column(variable)
11210                                            .and_then(|column| values.get(column))
11211                                            .and_then(Value::as_str)
11212                                            .is_some_and(|value| {
11213                                                allowed.iter().any(|allowed| allowed == value)
11214                                            })
11215                                    })
11216                            })
11217                            .map_err(|message| {
11218                                SectionError::Abort("storage.failed".to_owned(), message)
11219                            })?;
11220                        for commit in dropped {
11221                            batch.conflicts = true;
11222                            batch.rejections = true;
11223                            batch.outcomes = true;
11224                            for operation in commit.ops {
11225                                batch.table(&operation.table);
11226                            }
11227                        }
11228                        let purged = self.purge_scope_rows(&table, &effective);
11229                        match purged {
11230                            Ok(()) => {
11231                                self.record_scope_map(&mut batch, &table, &effective);
11232                                let sub = &mut self.subs[sub_index];
11233                                sub.state = SubState::Revoked;
11234                                sub.reason_code = Some(if reason_code.is_empty() {
11235                                    "sync.scope_revoked".to_owned()
11236                                } else {
11237                                    reason_code.to_owned()
11238                                });
11239                                report.revoked.push(id.to_owned());
11240                                let doomed_effective = effective;
11241                                let sub_table = table;
11242                                self.persist_sub(&self.subs[sub_index].clone()).map_err(
11243                                    |message| SectionError::Abort("storage.failed".into(), message),
11244                                )?;
11245                                let dropped = self
11246                                    .drop_doomed_outbox(&sub_table, &doomed_effective)
11247                                    .map_err(|message| {
11248                                        SectionError::Abort("storage.failed".to_owned(), message)
11249                                    })?;
11250                                if dropped {
11251                                    batch.status = true;
11252                                    batch.rejections = true;
11253                                    batch.outcomes = true;
11254                                }
11255                                // §5.9.7 B2: revocation deletes now-unauthorized blob
11256                                // bodies (evicted ≠ revoked).
11257                                self.delete_unreferenced_cached_blobs();
11258                            }
11259                            Err(()) => {
11260                                // §3.3 fail closed: no local mapping — never clear by
11261                                // approximation; fatal configuration error.
11262                                let sub = &mut self.subs[sub_index];
11263                                sub.state = SubState::Failed;
11264                                sub.reason_code = Some("sync.scope_revoked".to_owned());
11265                                report.failed.push(id.to_owned());
11266                                self.persist_sub(&self.subs[sub_index].clone()).map_err(
11267                                    |message| SectionError::Abort("storage.failed".into(), message),
11268                                )?;
11269                            }
11270                        }
11271                        if let Some((base_key, unit)) = registered {
11272                            batch.window(&base_key, &self.subs[sub_index].table, &unit);
11273                        }
11274                        self.rebuild_overlay_if_dirty();
11275                        self.finish_observation("syncular_revocation", batch)
11276                            .map_err(|message| {
11277                                SectionError::Abort("storage.failed".to_owned(), message)
11278                            })?;
11279                        Ok(())
11280                    })();
11281                if result.is_err() {
11282                    self.rollback_observation("syncular_revocation");
11283                    self.subs[sub_index] = previous;
11284                    self.overlay_dirty.restore(was_dirty.clone());
11285                    self.outbox = previous_outbox;
11286                    self.failed_commits = previous_failed;
11287                    self.rejections = previous_rejections;
11288                    self.conflicts = previous_conflicts;
11289                    report.revoked.truncate(revoked_count);
11290                    report.failed.truncate(failed_count);
11291                }
11292                result
11293            }
11294            SubStatus::Reset => {
11295                self.begin_observation("syncular_reset")
11296                    .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
11297                let previous = self.subs[sub_index].clone();
11298                let was_dirty = self.overlay_dirty.snapshot();
11299                let reset_count = report.resets.len();
11300                let result = (|| {
11301                    let mut batch = ChangeAccumulator::default();
11302                    let registered = self.window_unit_by_sub_id(id);
11303                    // §4.6: discard cursor + bootstrap state, keep local rows —
11304                    // reset is a staleness signal, not a purge signal.
11305                    let sub = &mut self.subs[sub_index];
11306                    sub.cursor = -1;
11307                    sub.bootstrap_state = None;
11308                    report.resets.push(id.to_owned());
11309                    self.persist_sub(&self.subs[sub_index].clone())
11310                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11311                    if let Some((base_key, unit)) = registered {
11312                        batch.window(&base_key, &self.subs[sub_index].table, &unit);
11313                    }
11314                    self.finish_observation("syncular_reset", batch)
11315                        .map_err(|message| {
11316                            SectionError::Abort("storage.failed".to_owned(), message)
11317                        })?;
11318                    Ok(())
11319                })();
11320                if result.is_err() {
11321                    self.rollback_observation("syncular_reset");
11322                    self.subs[sub_index] = previous;
11323                    self.overlay_dirty.restore(was_dirty.clone());
11324                    report.resets.truncate(reset_count);
11325                }
11326                result
11327            }
11328            SubStatus::Active => {
11329                let fresh = meta
11330                    .fresh
11331                    .iter()
11332                    .find(|(fid, _)| fid == id)
11333                    .map(|(_, fresh)| *fresh)
11334                    .unwrap_or(false);
11335                let previous = self.subs[sub_index].clone();
11336                self.subs[sub_index].effective = Some(effective_scopes);
11337                let outcome = self
11338                    .apply_section_body(transport, sub_index, body, fresh, meta, report)
11339                    .and_then(|()| {
11340                        let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
11341                            SectionError::Abort(
11342                                "sync.invalid_request".into(),
11343                                "subscription section without SUB_END".into(),
11344                            )
11345                        })?;
11346                        self.apply_sub_end(sub_index, cursor, bootstrap_state)
11347                            .map_err(|message| {
11348                                SectionError::Abort("storage.failed".into(), message)
11349                            })
11350                    });
11351                match outcome {
11352                    Ok(()) => {
11353                        if self.subs[sub_index].bootstrap_state.is_some() {
11354                            report.bootstrapping.push(id.to_owned());
11355                        }
11356                        Ok(())
11357                    }
11358                    Err(SectionError::FailClosed) => {
11359                        self.subs[sub_index] = previous.clone();
11360                        self.begin_observation("syncular_section_failure")
11361                            .map_err(|message| {
11362                                SectionError::Abort("storage.failed".into(), message)
11363                            })?;
11364                        let persisted = (|| {
11365                            let mut batch = ChangeAccumulator::default();
11366                            if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
11367                                batch.window(&base_key, &self.subs[sub_index].table, &unit);
11368                            }
11369                            self.subs[sub_index].state = SubState::Failed;
11370                            self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
11371                            self.persist_sub(&self.subs[sub_index])?;
11372                            self.finish_observation("syncular_section_failure", batch)
11373                        })();
11374                        if let Err(message) = persisted {
11375                            self.rollback_observation("syncular_section_failure");
11376                            self.subs[sub_index] = previous;
11377                            return Err(SectionError::Abort("storage.failed".into(), message));
11378                        }
11379                        report.failed.push(id.to_owned());
11380                        Ok(())
11381                    }
11382                    Err(error) => {
11383                        // Frame/block transactions already completed. Preserve their
11384                        // rows and revisions, but leave this subscription cursor unchanged.
11385                        self.subs[sub_index] = previous;
11386                        Err(error)
11387                    }
11388                }
11389            }
11390        }
11391    }
11392
11393    fn apply_sub_end(
11394        &mut self,
11395        sub_index: usize,
11396        cursor: i64,
11397        bootstrap_state: Option<String>,
11398    ) -> Result<(), String> {
11399        #[cfg(feature = "bench-internals")]
11400        let _phase = self.benchmark_phases.start(Phase::CursorPersist);
11401        let previous = self.subs[sub_index].clone();
11402        self.begin_observation("syncular_sub_end")?;
11403        let persisted = (|| {
11404            let mut batch = ChangeAccumulator::default();
11405            let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
11406                && cursor >= 0
11407                && bootstrap_state.is_none();
11408            if completed {
11409                let table = self
11410                    .schema
11411                    .table(&previous.table)
11412                    .ok_or_else(|| "sync.unknown_table".to_owned())?;
11413                let effective = previous.effective.as_deref().unwrap_or(&[]);
11414                let mut statement = self.conn.prepare("SELECT id,intent_json FROM _syncular_acked_rows WHERE tbl=?1 AND commit_seq<=?2")
11415                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11416                let rows = statement
11417                    .query_map(rusqlite::params![previous.table, cursor], |row| {
11418                        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
11419                    })
11420                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11421                let mut covered = HashSet::new();
11422                for row in rows {
11423                    let (id, intent) =
11424                        row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11425                    let values: Map<String, Value> = serde_json::from_str(&intent)
11426                        .map_err(|_| "sync.local_corrupt".to_owned())?;
11427                    if !effective.is_empty()
11428                        && effective.iter().all(|(variable, allowed)| {
11429                            table
11430                                .scope_column(variable)
11431                                .and_then(|column| values.get(column))
11432                                .is_some_and(|value| {
11433                                    allowed.contains(
11434                                        &value
11435                                            .as_str()
11436                                            .map_or_else(|| value.to_string(), str::to_owned),
11437                                    )
11438                                })
11439                        })
11440                    {
11441                        covered.insert(id);
11442                    }
11443                }
11444                drop(statement);
11445                for id in covered {
11446                    self.conn.execute("DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3", rusqlite::params![previous.table, id, cursor])
11447                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11448                    self.overlay_dirty.table(&previous.table);
11449                    batch.table(&previous.table);
11450                }
11451                self.rebuild_overlay_if_dirty();
11452                if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
11453                    batch.window(&base_key, &previous.table, &unit);
11454                }
11455            }
11456            let sub = &mut self.subs[sub_index];
11457            sub.cursor = cursor;
11458            sub.bootstrap_state = bootstrap_state;
11459            sub.synced_once = true;
11460            self.persist_sub(&self.subs[sub_index])?;
11461            self.finish_observation("syncular_sub_end", batch)
11462        })();
11463        if persisted.is_err() {
11464            self.rollback_observation("syncular_sub_end");
11465            self.subs[sub_index] = previous;
11466        }
11467        persisted
11468    }
11469
11470    fn apply_section_body(
11471        &mut self,
11472        transport: &mut dyn Transport,
11473        sub_index: usize,
11474        body: Vec<Frame>,
11475        fresh: bool,
11476        meta: &RequestMeta,
11477        report: &mut SyncReport,
11478    ) -> Result<(), SectionError> {
11479        let mut saw_segment = false;
11480        for frame in body {
11481            match frame {
11482                Frame::Commit {
11483                    tables,
11484                    changes,
11485                    commit_seq,
11486                    ..
11487                } => {
11488                    self.apply_commit_frame(&tables, &changes, Some(commit_seq))
11489                        .map_err(|(c, m)| SectionError::Abort(c, m))?;
11490                    report.commits_applied += 1;
11491                }
11492                Frame::SegmentInline { payload } => {
11493                    let segment = decode_rows_segment(&payload)
11494                        .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
11495                    self.progress.update(|p| {
11496                        p.phase = ProgressPhase::Import;
11497                        p.subscription_id = Some(self.subs[sub_index].id.clone());
11498                        p.table = Some(self.subs[sub_index].table.clone());
11499                        p.segment_id = None;
11500                        p.bytes_received = payload.len() as u64;
11501                        p.bytes_total = Some(payload.len() as u64);
11502                        p.rows_processed = 0;
11503                        p.rows_total =
11504                            Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
11505                    });
11506                    let first = !saw_segment;
11507                    saw_segment = true;
11508                    let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
11509                    report.segment_rows_applied += applied;
11510                }
11511                Frame::SegmentRef {
11512                    segment_id,
11513                    byte_length,
11514                    media_type,
11515                    table,
11516                    row_count,
11517                    as_of_commit_seq,
11518                    scope_digest,
11519                    row_cursor,
11520                    next_row_cursor,
11521                    url,
11522                    url_expires_at_ms,
11523                    ..
11524                } => {
11525                    // §4.2: reject a descriptor whose mediaType was not
11526                    // advertised — never skip or guess.
11527                    let advertised = match media_type {
11528                        MediaType::Rows => {
11529                            meta.accept & ACCEPT_EXTERNAL_ROWS != 0
11530                                || meta.accept & ACCEPT_INLINE_ROWS != 0
11531                        }
11532                        MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
11533                    };
11534                    if !advertised {
11535                        return Err(SectionError::Abort(
11536                            "sync.invalid_request".to_owned(),
11537                            format!(
11538                                "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
11539                                media_type.name()
11540                            ),
11541                        ));
11542                    }
11543                    self.progress.update(|p| {
11544                        p.phase = ProgressPhase::Download;
11545                        p.subscription_id = Some(self.subs[sub_index].id.clone());
11546                        p.table = Some(table.clone());
11547                        p.segment_id = Some(segment_id.clone());
11548                        p.bytes_received = 0;
11549                        p.bytes_total = u64::try_from(byte_length).ok();
11550                        p.rows_processed = 0;
11551                        p.rows_total = u64::try_from(row_count).ok();
11552                    });
11553                    let observer = self.progress.clone();
11554                    let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
11555                    let bytes = if let Some(url) = url {
11556                        // §5.4: a url-carrying descriptor MUST be fetched
11557                        // from that URL; failure invalidates the whole
11558                        // descriptor (no fall-through to §5.5 — re-pull
11559                        // recovers, §1.4 rule 5).
11560                        if meta.accept & ACCEPT_SIGNED_URLS == 0 {
11561                            return Err(SectionError::Abort(
11562                                "sync.invalid_request".to_owned(),
11563                                "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
11564                                    .to_owned(),
11565                            ));
11566                        }
11567                        // §5.4: MUST NOT start a fetch at/past expiry.
11568                        if url_expires_at_ms.is_some_and(|exp| {
11569                            exp <= transport
11570                                .segment_fetch_started_at(&url)
11571                                .unwrap_or_else(|| self.clock_now_ms())
11572                        }) {
11573                            return Err(SectionError::Abort(
11574                                "sync.segment_expired".to_owned(),
11575                                format!(
11576                                    "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
11577                                ),
11578                            ));
11579                        }
11580                        transport
11581                            .fetch_url(&url, &mut on_progress)
11582                            .map_err(|e| SectionError::Abort(e.code, e.message))?
11583                    } else {
11584                        let requested_scopes_json =
11585                            canonical_scope_json(&self.subs[sub_index].requested);
11586                        transport
11587                            .download_segment(
11588                                &SegmentRequest {
11589                                    segment_id: segment_id.clone(),
11590                                    table,
11591                                    requested_scopes_json,
11592                                },
11593                                &mut on_progress,
11594                            )
11595                            .map_err(|e| SectionError::Abort(e.code, e.message))?
11596                    };
11597                    // §5.1: verify the content address before applying.
11598                    on_progress(bytes.len() as u64);
11599                    let digest = Sha256::digest(&bytes);
11600                    let expected = segment_id
11601                        .strip_prefix("sha256:")
11602                        .unwrap_or(segment_id.as_str());
11603                    if bytes_to_hex(&digest) != expected {
11604                        // §5.1: the client discards the segment and re-pulls.
11605                        self.schedule_background_retry();
11606                        return Err(SectionError::Abort(
11607                            "sync.invalid_request".to_owned(),
11608                            "segment bytes do not match the content address (§5.1)".to_owned(),
11609                        ));
11610                    }
11611                    self.progress.update(|p| p.phase = ProgressPhase::Import);
11612                    if media_type == MediaType::Sqlite {
11613                        // §5.3: images are whole-table — a paged sqlite
11614                        // descriptor is invalid.
11615                        if row_cursor.is_some() || next_row_cursor.is_some() {
11616                            return Err(SectionError::Abort(
11617                                "sync.invalid_request".to_owned(),
11618                                "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
11619                                    .to_owned(),
11620                            ));
11621                        }
11622                        let first = !saw_segment;
11623                        saw_segment = true;
11624                        let applied = self.apply_sqlite_segment(
11625                            sub_index,
11626                            &bytes,
11627                            fresh && first,
11628                            row_count,
11629                            as_of_commit_seq,
11630                            &scope_digest,
11631                        )?;
11632                        report.segment_rows_applied += applied;
11633                    } else {
11634                        let segment = decode_rows_segment(&bytes).map_err(|e| {
11635                            SectionError::Abort(e.code.as_str().to_owned(), e.detail)
11636                        })?;
11637                        let first = row_cursor.is_none();
11638                        saw_segment = true;
11639                        let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
11640                        report.segment_rows_applied += applied;
11641                    }
11642                }
11643                Frame::Unknown { .. } => {}
11644                Frame::Error { code, message, .. } => {
11645                    return Err(SectionError::Abort(code, message))
11646                }
11647                _ => {
11648                    return Err(SectionError::Abort(
11649                        "sync.invalid_request".to_owned(),
11650                        "unexpected frame inside a subscription section".to_owned(),
11651                    ));
11652                }
11653            }
11654        }
11655        Ok(())
11656    }
11657
11658    // Both pull and realtime frames use the same durable observation boundary.
11659    fn apply_commit_frame(
11660        &mut self,
11661        tables: &[String],
11662        changes: &[ssp2::model::Change],
11663        commit_seq: Option<i64>,
11664    ) -> Result<(), (String, String)> {
11665        #[cfg(feature = "bench-internals")]
11666        let _phase = self.benchmark_phases.start(Phase::CommitApply);
11667        let was_dirty = self.overlay_dirty.snapshot();
11668        self.begin_observation("syncular_commit")
11669            .map_err(|message| ("storage.failed".into(), message))?;
11670        let applied = (|| {
11671            let mut batch = ChangeAccumulator::default();
11672            self.record_commit_changes(&mut batch, tables, changes);
11673            if let Some(sequence) = commit_seq {
11674                for change in changes {
11675                    let table = tables.get(change.table_index as usize).ok_or_else(|| {
11676                        (
11677                            "sync.invalid_request".to_owned(),
11678                            "change tableIndex out of range".to_owned(),
11679                        )
11680                    })?;
11681                    if self
11682                        .outbox
11683                        .iter()
11684                        .flat_map(|commit| &commit.ops)
11685                        .any(|op| op.table == *table && op.row_id == change.row_id)
11686                    {
11687                        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])
11688                        .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
11689                    }
11690                    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])
11691                        .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
11692                }
11693            }
11694            self.apply_commit_changes(tables, changes)?;
11695            self.rebuild_overlay_if_dirty();
11696            self.finish_observation("syncular_commit", batch)
11697                .map_err(|message| ("storage.failed".into(), message))
11698        })();
11699        if applied.is_err() {
11700            self.rollback_observation("syncular_commit");
11701            self.overlay_dirty.restore(was_dirty.clone());
11702        }
11703        applied
11704    }
11705
11706    fn apply_commit_changes(
11707        &mut self,
11708        tables: &[String],
11709        changes: &[ssp2::model::Change],
11710    ) -> Result<(), (String, String)> {
11711        // A clean overlay can reconcile changed primary keys independently
11712        // when their tables have no secondary unique constraints. Such a
11713        // constraint can change whether another row's pending write succeeds,
11714        // so those frames still require the complete FIFO replay below.
11715        let mirror_visible = !self.overlay_dirty.get()
11716            && self.failed_commits.is_empty()
11717            && ((self.outbox.is_empty() && !self.has_acknowledged_rows())
11718                || changes.iter().all(|change| {
11719                    tables
11720                        .get(change.table_index as usize)
11721                        .and_then(|name| self.schema.table(name))
11722                        .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
11723                }));
11724        for change in changes {
11725            let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
11726                (
11727                    "sync.invalid_request".to_owned(),
11728                    "change tableIndex out of range".to_owned(),
11729                )
11730            })?;
11731            let table = self.schema.table(table_name).ok_or_else(|| {
11732                (
11733                    "sync.schema_mismatch".to_owned(),
11734                    format!("change targets unknown table {table_name:?}"),
11735                )
11736            })?;
11737            match change.op {
11738                Op::Upsert => {
11739                    let payload = change.row.as_ref().ok_or_else(|| {
11740                        (
11741                            "sync.invalid_request".to_owned(),
11742                            "upsert change without row payload".to_owned(),
11743                        )
11744                    })?;
11745                    // §5.11: decrypt encrypted columns on apply.
11746                    let row = {
11747                        #[cfg(feature = "bench-internals")]
11748                        let _phase = self.benchmark_phases.start(Phase::RowDecode);
11749                        decode_row_bytes(table, payload, &self.encryption)
11750                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?
11751                    };
11752                    let version = change.row_version.unwrap_or(0);
11753                    let table_name = table.name.clone();
11754                    self.write_base_row(&table_name, &row, version)
11755                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
11756                    if mirror_visible {
11757                        self.write_row(&visible_table(&table_name), &table_name, &row, version)
11758                            .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
11759                    }
11760                }
11761                Op::Delete => {
11762                    self.delete_base_row(table_name, &change.row_id)
11763                        .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
11764                    if mirror_visible {
11765                        let sql = format!(
11766                            "DELETE FROM {} WHERE {}",
11767                            visible_table(table_name),
11768                            row_id_predicate(table)
11769                        );
11770                        self.conn
11771                            .prepare_cached(&sql)
11772                            .and_then(|mut statement| {
11773                                statement.execute(rusqlite::params![change.row_id])
11774                            })
11775                            .map_err(|error| {
11776                                ("sync.invalid_request".to_owned(), error.to_string())
11777                            })?;
11778                    }
11779                }
11780            }
11781        }
11782        if mirror_visible {
11783            {
11784                let changed_rows: HashSet<(&str, &str)> = changes
11785                    .iter()
11786                    .map(|change| {
11787                        (
11788                            tables[change.table_index as usize].as_str(),
11789                            change.row_id.as_str(),
11790                        )
11791                    })
11792                    .collect();
11793                let acknowledged = self
11794                    .acknowledged_operations(Some(&changed_rows))
11795                    .map_err(|error| ("storage.failed".to_owned(), error))?;
11796                self.apply_outbox_ops(&acknowledged);
11797                self.apply_outbox_ops(
11798                    self.outbox
11799                        .iter()
11800                        .flat_map(|commit| &commit.ops)
11801                        .filter(|op| {
11802                            changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
11803                        }),
11804                );
11805            }
11806            self.overlay_dirty.set(false);
11807        }
11808        Ok(())
11809    }
11810
11811    /// §5.6 segment application: validate against the generated schema,
11812    /// clear the grant on a fresh bootstrap's first page (fail closed
11813    /// without a mapping), then replace-or-upsert each row with its
11814    /// segment-carried server version (§5.2).
11815    fn apply_segment(
11816        &mut self,
11817        sub_index: usize,
11818        segment: &RowsSegment,
11819        first_fresh_page: bool,
11820    ) -> Result<u32, SectionError> {
11821        let (sub_table, effective) = {
11822            let sub = &self.subs[sub_index];
11823            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
11824        };
11825        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
11826            SectionError::Abort(
11827                "sync.schema_mismatch".to_owned(),
11828                format!("subscription table {sub_table:?} is not in the client schema"),
11829            )
11830        })?;
11831        // §5.2: the column table validates against the generated schema —
11832        // order, names, types, nullability; mismatch is fatal. §5.11: the
11833        // server sends the WIRE types (bytes for an encrypted column), so
11834        // validate against wire_columns.
11835        let matches = segment.table == table.name
11836            && segment.schema_version == self.schema.version
11837            && segment.columns.len() == table.wire_columns.len()
11838            && segment
11839                .columns
11840                .iter()
11841                .zip(table.wire_columns.iter())
11842                .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
11843        if !matches {
11844            return Err(SectionError::Abort(
11845                "sync.schema_mismatch".to_owned(),
11846                "segment column table does not match the generated schema (§5.2)".to_owned(),
11847            ));
11848        }
11849        let mut applied = 0u32;
11850        let empty = Vec::new();
11851        let blocks = if segment.blocks.is_empty() {
11852            std::slice::from_ref(&empty)
11853        } else {
11854            &segment.blocks
11855        };
11856        for (index, block) in blocks.iter().enumerate() {
11857            let clear = first_fresh_page && index == 0;
11858            if !clear && block.is_empty() {
11859                continue;
11860            }
11861            let was_dirty = self.overlay_dirty.snapshot();
11862            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
11863                && self
11864                    .outbox
11865                    .iter()
11866                    .flat_map(|commit| &commit.ops)
11867                    .any(|op| op.table == table.name);
11868            let mirror_visible = !reconcile_pending
11869                && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
11870            self.begin_observation("syncular_segment_block")
11871                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11872            let outcome = (|| {
11873                self.rebuild_overlay_if_dirty();
11874                let mut changed_keys = Vec::new();
11875                let mut batch = ChangeAccumulator::default();
11876                if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
11877                    batch.table(&table.name);
11878                }
11879                if clear {
11880                    self.purge_scope_rows(&table.name, &effective)
11881                        .map_err(|()| SectionError::FailClosed)?;
11882                }
11883                for (row_index, row) in block.iter().enumerate() {
11884                    let decrypted;
11885                    let values = if table.has_encrypted_columns() {
11886                        let mut values = row.values.clone();
11887                        crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
11888                            .map_err(|message| {
11889                            SectionError::Abort("client.decrypt_failed".into(), message)
11890                        })?;
11891                        decrypted = values;
11892                        &decrypted
11893                    } else {
11894                        &row.values
11895                    };
11896                    self.write_base_row(&table.name, values, row.server_version)
11897                        .map_err(|message| {
11898                            SectionError::Abort("sync.invalid_request".into(), message)
11899                        })?;
11900                    if reconcile_pending {
11901                        changed_keys.push(
11902                            owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
11903                                |message| SectionError::Abort("storage.failed".into(), message),
11904                            )?,
11905                        );
11906                    }
11907                    if mirror_visible {
11908                        self.write_row(
11909                            &visible_table(&table.name),
11910                            &table.name,
11911                            values,
11912                            row.server_version,
11913                        )
11914                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11915                    }
11916                    let processed = u64::from(applied) + row_index as u64 + 1;
11917                    if processed.is_multiple_of(1024) {
11918                        self.progress.update(|p| p.rows_processed = processed);
11919                    }
11920                }
11921                if reconcile_pending {
11922                    self.reconcile_imported_rows(&table, &mut changed_keys)
11923                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
11924                }
11925                if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
11926                    self.apply_outbox_ops(
11927                        self.outbox
11928                            .iter()
11929                            .flat_map(|commit| &commit.ops)
11930                            .filter(|op| op.table == table.name),
11931                    );
11932                    self.overlay_dirty.set(false);
11933                } else {
11934                    self.rebuild_overlay();
11935                }
11936                self.finish_observation("syncular_segment_block", batch)
11937                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))
11938            })();
11939            if let Err(error) = outcome {
11940                self.rollback_observation("syncular_segment_block");
11941                self.overlay_dirty.restore(was_dirty.clone());
11942                return Err(error);
11943            }
11944            applied += block.len() as u32;
11945            self.progress
11946                .update(|p| p.rows_processed = u64::from(applied));
11947            std::thread::yield_now();
11948        }
11949        self.progress
11950            .update(|p| p.rows_processed = u64::from(applied));
11951        Ok(applied)
11952    }
11953
11954    /// §5.3 sqlite-image application: validate the in-file metadata
11955    /// against the descriptor, validate column names/order against the
11956    /// generated schema, run the §5.6 first-page clear when fresh, then
11957    /// replace-or-upsert every image row with its `_syncular_version`.
11958    /// Mechanics: the image lands in a temp file read through a second
11959    /// rusqlite connection, equivalent to ATTACH + INSERT…SELECT.
11960    /// Each image chunk commits its writes and revision before readers continue.
11961    fn apply_sqlite_segment(
11962        &mut self,
11963        sub_index: usize,
11964        bytes: &[u8],
11965        first_fresh_page: bool,
11966        row_count: i64,
11967        as_of_commit_seq: i64,
11968        scope_digest: &str,
11969    ) -> Result<u32, SectionError> {
11970        let invalid = |detail: &str| {
11971            SectionError::Abort(
11972                "sync.invalid_request".to_owned(),
11973                format!("sqlite segment rejected: {detail} (§5.3)"),
11974            )
11975        };
11976        let (sub_table, effective) = {
11977            let sub = &self.subs[sub_index];
11978            (sub.table.clone(), sub.effective.clone().unwrap_or_default())
11979        };
11980        let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
11981            SectionError::Abort(
11982                "sync.schema_mismatch".to_owned(),
11983                format!("subscription table {sub_table:?} is not in the client schema"),
11984            )
11985        })?;
11986
11987        let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
11988        std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
11989        let img = match rusqlite::Connection::open_with_flags(
11990            &path,
11991            rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
11992        ) {
11993            Ok(conn) => conn,
11994            Err(_) => {
11995                let _ = std::fs::remove_file(&path);
11996                return Err(invalid("bytes do not open as a SQLite database"));
11997            }
11998        };
11999        let outcome = self.apply_sqlite_image(
12000            &img,
12001            &table,
12002            first_fresh_page,
12003            &effective,
12004            row_count,
12005            as_of_commit_seq,
12006            scope_digest,
12007        );
12008        drop(img);
12009        let _ = std::fs::remove_file(&path);
12010        outcome
12011    }
12012
12013    #[allow(clippy::too_many_arguments)]
12014    fn apply_sqlite_image(
12015        &mut self,
12016        img: &rusqlite::Connection,
12017        table: &crate::schema::TableSchema,
12018        first_fresh_page: bool,
12019        effective: &[(String, Vec<String>)],
12020        row_count: i64,
12021        as_of_commit_seq: i64,
12022        scope_digest: &str,
12023    ) -> Result<u32, SectionError> {
12024        let invalid = |detail: String| {
12025            SectionError::Abort(
12026                "sync.invalid_request".to_owned(),
12027                format!("sqlite segment rejected: {detail} (§5.3)"),
12028            )
12029        };
12030
12031        // 1. Metadata vs descriptor + client state (§5.3 rule 2; exactly
12032        //    one row).
12033        type MetaRow = (i64, String, i64, i64, String, i64, i64);
12034        let meta: MetaRow = img
12035            .query_row(
12036                "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
12037                        \"scopeDigest\", \"rowCount\",
12038                        (SELECT count(*) FROM _syncular_segment)
12039                 FROM _syncular_segment",
12040                [],
12041                |row| {
12042                    Ok((
12043                        row.get(0)?,
12044                        row.get(1)?,
12045                        row.get(2)?,
12046                        row.get(3)?,
12047                        row.get(4)?,
12048                        row.get(5)?,
12049                        row.get(6)?,
12050                    ))
12051                },
12052            )
12053            .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
12054        let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
12055        if meta_count != 1 {
12056            return Err(invalid(format!(
12057                "_syncular_segment must contain exactly one row, found {meta_count}"
12058            )));
12059        }
12060        if format != 1 {
12061            return Err(invalid(format!("format {format}")));
12062        }
12063        if meta_table != table.name {
12064            return Err(invalid(format!("image table {meta_table:?}")));
12065        }
12066        if schema_version != i64::from(self.schema.version) {
12067            return Err(invalid(format!("schemaVersion {schema_version}")));
12068        }
12069        if pin != as_of_commit_seq {
12070            return Err(invalid(format!("asOfCommitSeq {pin}")));
12071        }
12072        if digest != scope_digest {
12073            return Err(invalid("scopeDigest mismatch".to_owned()));
12074        }
12075        if meta_rows != row_count {
12076            return Err(invalid(format!("rowCount {meta_rows}")));
12077        }
12078
12079        // 2. Column names and order vs the generated schema (§5.3 rule 3).
12080        let mut names: Vec<String> = Vec::new();
12081        let mut primary_keys: Vec<String> = Vec::new();
12082        {
12083            let mut stmt = img
12084                .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
12085                .map_err(|_| invalid("image data table missing".to_owned()))?;
12086            let mut rows = stmt
12087                .query([])
12088                .map_err(|_| invalid("image data table unreadable".to_owned()))?;
12089            while let Some(row) = rows
12090                .next()
12091                .map_err(|_| invalid("image data table unreadable".to_owned()))?
12092            {
12093                if row
12094                    .get::<_, i64>(5)
12095                    .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?
12096                    > 0
12097                {
12098                    primary_keys.push(row.get(1).map_err(|error| {
12099                        invalid(Self::sqlite_failure(&self.storage_failure, error))
12100                    })?);
12101                }
12102                names.push(
12103                    row.get::<_, String>(1)
12104                        .map_err(|_| invalid("image data table unreadable".to_owned()))?,
12105                );
12106            }
12107        }
12108        let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
12109        expected.push("_syncular_version");
12110        if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
12111            return Err(SectionError::Abort(
12112                "sync.schema_mismatch".to_owned(),
12113                "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
12114            ));
12115        }
12116
12117        if primary_keys != vec![table.primary_key.clone()] {
12118            return Err(invalid(
12119                "image primary key does not match generated schema".into(),
12120            ));
12121        }
12122        let actual_count: i64 = img
12123            .query_row(
12124                &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
12125                [],
12126                |row| row.get(0),
12127            )
12128            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
12129        if actual_count != row_count {
12130            return Err(invalid("image row count does not match descriptor".into()));
12131        }
12132        let insert = self.insert_row_sql(&base_table(&table.name), table);
12133        let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
12134        let column_list = names
12135            .iter()
12136            .map(|n| quote_ident(n))
12137            .collect::<Vec<_>>()
12138            .join(", ");
12139        let mut stmt = img
12140            .prepare(&format!(
12141                "SELECT {column_list} FROM {} ORDER BY {}",
12142                quote_ident(&table.name),
12143                quote_ident(&table.primary_key)
12144            ))
12145            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
12146        let mut rows = stmt
12147            .query([])
12148            .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
12149        let mut applied = 0u32;
12150        loop {
12151            let clear = applied == 0 && first_fresh_page;
12152            let was_dirty = self.overlay_dirty.snapshot();
12153            let reconcile_pending = table.indexes.iter().any(|index| index.unique)
12154                && self
12155                    .outbox
12156                    .iter()
12157                    .flat_map(|commit| &commit.ops)
12158                    .any(|op| op.table == table.name);
12159            let mirror_visible = !reconcile_pending
12160                && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
12161            self.begin_observation("syncular_image_chunk")
12162                .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
12163            let result = (|| {
12164                self.rebuild_overlay_if_dirty();
12165                let mut changed_keys = Vec::new();
12166                let mut batch = ChangeAccumulator::default();
12167                if clear {
12168                    if self.scoped_rows_exist(&table.name, effective) {
12169                        batch.table(&table.name);
12170                    }
12171                    self.purge_scope_rows(&table.name, effective)
12172                        .map_err(|()| SectionError::FailClosed)?;
12173                }
12174                let mut processed = 0u32;
12175                {
12176                    let mut ins = self.conn.prepare_cached(&insert).map_err(|error| {
12177                        invalid(Self::sqlite_failure(&self.storage_failure, error))
12178                    })?;
12179                    let mut mirror = if mirror_visible {
12180                        Some(self.conn.prepare_cached(&visible_insert).map_err(|error| {
12181                            invalid(Self::sqlite_failure(&self.storage_failure, error))
12182                        })?)
12183                    } else {
12184                        None
12185                    };
12186                    for _ in 0..1024 {
12187                        let Some(row) = rows.next().map_err(|error| {
12188                            invalid(Self::sqlite_failure(&self.storage_failure, error))
12189                        })?
12190                        else {
12191                            break;
12192                        };
12193                        for (i, column) in table.columns.iter().enumerate() {
12194                            let cell = row.get_ref(i).map_err(|error| {
12195                                invalid(Self::sqlite_failure(&self.storage_failure, error))
12196                            })?;
12197                            let param = image_cell_param(column, cell).map_err(invalid)?;
12198                            ins.raw_bind_parameter(i + 1, &param).map_err(|error| {
12199                                invalid(Self::sqlite_failure(&self.storage_failure, error))
12200                            })?;
12201                            if let Some(mirror) = &mut mirror {
12202                                mirror.raw_bind_parameter(i + 1, &param).map_err(|error| {
12203                                    invalid(Self::sqlite_failure(&self.storage_failure, error))
12204                                })?;
12205                            }
12206                        }
12207                        let version_index = table.columns.len();
12208                        let version: i64 = row.get(version_index).map_err(|error| {
12209                            invalid(Self::sqlite_failure(&self.storage_failure, error))
12210                        })?;
12211                        if version < 1 {
12212                            return Err(invalid("image row version must be positive".into()));
12213                        }
12214                        ins.raw_bind_parameter(version_index + 1, version)
12215                            .map_err(|error| {
12216                                invalid(Self::sqlite_failure(&self.storage_failure, error))
12217                            })?;
12218                        ins.raw_execute().map_err(|error| {
12219                            invalid(Self::sqlite_failure(&self.storage_failure, error))
12220                        })?;
12221                        if let Some(mirror) = &mut mirror {
12222                            mirror
12223                                .raw_bind_parameter(version_index + 1, version)
12224                                .map_err(|error| {
12225                                    invalid(Self::sqlite_failure(&self.storage_failure, error))
12226                                })?;
12227                            mirror.raw_execute().map_err(|error| {
12228                                invalid(Self::sqlite_failure(&self.storage_failure, error))
12229                            })?;
12230                        }
12231                        if reconcile_pending {
12232                            changed_keys.push(row.get::<_, SqlValue>(table.pk_index).map_err(
12233                                |error| invalid(Self::sqlite_failure(&self.storage_failure, error)),
12234                            )?);
12235                        }
12236                        processed += 1;
12237                    }
12238                }
12239                if processed > 0 {
12240                    batch.table(&table.name);
12241                    self.overlay_dirty.table(&table.name);
12242                }
12243                if reconcile_pending {
12244                    self.reconcile_imported_rows(table, &mut changed_keys)
12245                        .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
12246                }
12247                if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
12248                    self.apply_outbox_ops(
12249                        self.outbox
12250                            .iter()
12251                            .flat_map(|commit| &commit.ops)
12252                            .filter(|op| op.table == table.name),
12253                    );
12254                    self.overlay_dirty.set(false);
12255                } else {
12256                    self.rebuild_overlay();
12257                }
12258                self.finish_observation("syncular_image_chunk", batch)
12259                    .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
12260                Ok(processed)
12261            })();
12262            let processed = match result {
12263                Ok(processed) => processed,
12264                Err(error) => {
12265                    self.rollback_observation("syncular_image_chunk");
12266                    self.overlay_dirty.restore(was_dirty.clone());
12267                    return Err(error);
12268                }
12269            };
12270            applied += processed;
12271            self.progress
12272                .update(|p| p.rows_processed = u64::from(applied));
12273            std::thread::yield_now();
12274            if i64::from(applied) == actual_count {
12275                break;
12276            }
12277        }
12278        Ok(applied)
12279    }
12280
12281    /// Restore only imported and pending row identities before FIFO replay. All pending
12282    /// identities participate: a server row can free a unique value for a different edit.
12283    /// The caller owns the chunk transaction and publishes its revision after replay.
12284    fn reconcile_imported_rows(
12285        &self,
12286        table: &TableSchema,
12287        keys: &mut Vec<SqlValue>,
12288    ) -> Result<(), String> {
12289        let visible = visible_table(&table.name);
12290        let base = base_table(&table.name);
12291        let pk = quote_ident(&table.primary_key);
12292        let predicate = row_id_predicate(table);
12293        let mut lookup = self.conn.prepare_cached(&format!(
12294            "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
12295        )).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12296        let mut deleted = HashSet::new();
12297        for op in self
12298            .outbox
12299            .iter()
12300            .flat_map(|commit| &commit.ops)
12301            .filter(|op| op.table == table.name)
12302        {
12303            if op.upsert {
12304                if let Some(values) = &op.values {
12305                    let key = json_to_column_value(
12306                        &table.columns[table.pk_index],
12307                        values.get(&table.primary_key),
12308                    )?;
12309                    keys.push(owned_sql_value(RowParam::Cell(&key))?);
12310                }
12311            } else if deleted.insert(&op.row_id) {
12312                keys.extend(
12313                    lookup
12314                        .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
12315                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
12316                        .collect::<Result<Vec<_>, _>>()
12317                        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
12318                );
12319            }
12320        }
12321        // Delete the complete affected set before restoring any base value, so
12322        // old optimistic unique values cannot collide with the new server rows.
12323        for chunk in keys.chunks(400) {
12324            let holes = vec!["?"; chunk.len()].join(",");
12325            self.conn
12326                .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
12327                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
12328                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12329        }
12330        let updates = table
12331            .columns
12332            .iter()
12333            .map(|column| column.name.as_str())
12334            .chain(std::iter::once("_syncular_version"))
12335            .filter(|name| *name != table.primary_key)
12336            .map(|name| {
12337                let name = quote_ident(name);
12338                format!("{name}=excluded.{name}")
12339            })
12340            .collect::<Vec<_>>()
12341            .join(",");
12342        for chunk in keys.chunks(400) {
12343            let holes = vec!["?"; chunk.len()].join(",");
12344            self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
12345                .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
12346                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12347        }
12348        Ok(())
12349    }
12350
12351    // -- application-authorized local purge ---------------------------------------
12352
12353    fn compile_local_data_purge(
12354        &self,
12355        input: &LocalDataPurgeInput,
12356    ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
12357        let invalid = |message: String| format!("sync.invalid_request: {message}");
12358        if input.purge_id.is_empty()
12359            || input.purge_id.len() > 128
12360            || !is_local_operation_code_like(&input.purge_id)
12361        {
12362            return Err(invalid(
12363                "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
12364            ));
12365        }
12366        if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
12367            return Err(invalid(format!(
12368                "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
12369            )));
12370        }
12371
12372        let mut deduplicated: BTreeMap<
12373            String,
12374            (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
12375        > = BTreeMap::new();
12376        for target in &input.targets {
12377            let table = self.schema.table(&target.table).ok_or_else(|| {
12378                invalid(format!(
12379                    "local purge names unknown table {:?}",
12380                    target.table
12381                ))
12382            })?;
12383            if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
12384                return Err(invalid(format!(
12385                    "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
12386                    target.table
12387                )));
12388            }
12389            let mut selectors = Vec::with_capacity(target.selectors.len());
12390            let mut canonical_selectors = BTreeMap::new();
12391            for (column_name, raw_values) in &target.selectors {
12392                let Some((column_index, column)) = table
12393                    .columns
12394                    .iter()
12395                    .enumerate()
12396                    .find(|(_, column)| column.name == *column_name)
12397                else {
12398                    return Err(invalid(format!(
12399                        "local purge target {:?} names unknown column {:?}",
12400                        target.table, column_name
12401                    )));
12402                };
12403                let encrypted = table
12404                    .encrypted_columns
12405                    .iter()
12406                    .any(|candidate| candidate.index == column_index);
12407                if column.ty != ColumnType::String || encrypted {
12408                    return Err(invalid(format!(
12409                        "local purge selector {:?}.{:?} must be a plaintext string column",
12410                        target.table, column_name
12411                    )));
12412                }
12413                if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
12414                    return Err(invalid(format!(
12415                        "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
12416                        target.table, column_name
12417                    )));
12418                }
12419                let mut values = raw_values.clone();
12420                values.sort();
12421                values.dedup();
12422                if values.iter().any(|value| {
12423                    value.is_empty()
12424                        || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
12425                        || !is_local_operation_code_like(value)
12426                }) {
12427                    return Err(invalid(format!(
12428                        "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
12429                    )));
12430                }
12431                selectors.push((column_name.clone(), values.clone()));
12432                canonical_selectors.insert(column_name.clone(), values);
12433            }
12434            let canonical = LocalDataPurgeTarget {
12435                table: target.table.clone(),
12436                selectors: canonical_selectors,
12437            };
12438            let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
12439            deduplicated.insert(
12440                key,
12441                (
12442                    CompiledLocalDataPurgeTarget {
12443                        table: target.table.clone(),
12444                        selectors,
12445                    },
12446                    canonical,
12447                ),
12448            );
12449        }
12450        let targets = deduplicated
12451            .values()
12452            .map(|(compiled, _)| compiled.clone())
12453            .collect::<Vec<_>>();
12454        let canonical = deduplicated
12455            .values()
12456            .map(|(_, target)| target.clone())
12457            .collect::<Vec<_>>();
12458        let canonical_plan =
12459            serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
12460        Ok((targets, canonical_plan))
12461    }
12462
12463    fn local_purge_base_row_ids(
12464        &self,
12465        targets: &[CompiledLocalDataPurgeTarget],
12466    ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
12467        let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
12468        for target in targets {
12469            let table = self
12470                .schema
12471                .table(&target.table)
12472                .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
12473            let mut clauses = Vec::with_capacity(target.selectors.len());
12474            let mut params = Vec::new();
12475            for (column, values) in &target.selectors {
12476                clauses.push(format!(
12477                    "{} IN ({})",
12478                    quote_ident(column),
12479                    values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
12480                ));
12481                params.extend(values.iter().cloned().map(SqlValue::Text));
12482            }
12483            let sql = format!(
12484                "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
12485                quote_ident(&table.primary_key),
12486                base_table(&target.table),
12487                clauses.join(" AND ")
12488            );
12489            let mut statement = self
12490                .conn
12491                .prepare(&sql)
12492                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12493            let rows = statement
12494                .query_map(rusqlite::params_from_iter(params), |row| {
12495                    let value = sql_ref_to_json(&table.columns[table.pk_index], row.get_ref(0)?);
12496                    render_row_id_json(Some(&value))
12497                        .map_err(|message| rusqlite::Error::ToSqlConversionFailure(message.into()))
12498                })
12499                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12500            let ids = by_table.entry(target.table.clone()).or_default();
12501            for row in rows {
12502                ids.insert(
12503                    row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
12504                );
12505            }
12506        }
12507        Ok(by_table)
12508    }
12509
12510    fn local_purge_values_match(
12511        target: &CompiledLocalDataPurgeTarget,
12512        values: &Map<String, Value>,
12513    ) -> bool {
12514        target.selectors.iter().all(|(column, allowed)| {
12515            values
12516                .get(column)
12517                .and_then(Value::as_str)
12518                .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
12519        })
12520    }
12521
12522    /// Apply one host-authorized local security purge. The host MUST gate the
12523    /// corresponding subscriptions first; this primitive owns local SQLite
12524    /// cleanup and never grants/revokes server authority by itself.
12525    pub fn purge_local_data(
12526        &mut self,
12527        input: &LocalDataPurgeInput,
12528    ) -> Result<LocalDataPurgeResult, String> {
12529        let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
12530        self.cancel_sync_round();
12531        let meta_key = format!("localPurge:{}", input.purge_id);
12532        let applied_plan = self
12533            .conn
12534            .query_row(
12535                "SELECT value FROM _syncular_meta WHERE key = ?1",
12536                rusqlite::params![meta_key],
12537                |row| row.get::<_, String>(0),
12538            )
12539            .optional()
12540            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12541        if let Some(applied_plan) = applied_plan {
12542            if applied_plan != canonical_plan {
12543                return Err(format!(
12544                    "sync.invalid_request: local purge id {:?} was already used with a different plan",
12545                    input.purge_id
12546                ));
12547            }
12548            return Ok(LocalDataPurgeResult {
12549                already_applied: true,
12550                purged_rows: 0,
12551                dropped_commits: 0,
12552            });
12553        }
12554
12555        // RFC 0005 D7: the container file cannot be a purge target (its name is
12556        // not in the running schema), so purge drops it as a fixed step, before
12557        // any mirror row is touched and unconditionally.
12558        let _ = self.drop_previous_version();
12559
12560        let prior_outbox = self.outbox.clone();
12561        let prior_failed = self.failed_commits.clone();
12562        let prior_rejections = self.rejections.clone();
12563        let prior_conflicts = self.conflicts.clone();
12564        let prior_overlay_dirty = self.overlay_dirty.snapshot();
12565        self.begin_observation("syncular_local_purge")?;
12566        let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
12567            let row_ids = self.local_purge_base_row_ids(&targets)?;
12568            let retained_dropped = self.drop_failed_commits(|table, values| {
12569                targets.iter().any(|target| {
12570                    target.table == table && Self::local_purge_values_match(target, values)
12571                })
12572            })?;
12573            let doomed = self
12574                .outbox
12575                .iter()
12576                .filter(|commit| {
12577                    commit.ops.iter().any(|operation| {
12578                        let matching_targets = targets
12579                            .iter()
12580                            .filter(|target| target.table == operation.table)
12581                            .collect::<Vec<_>>();
12582                        if matching_targets.is_empty() {
12583                            return false;
12584                        }
12585                        if row_ids
12586                            .get(&operation.table)
12587                            .is_some_and(|ids| ids.contains(&operation.row_id))
12588                        {
12589                            return true;
12590                        }
12591                        operation.values.as_ref().is_some_and(|values| {
12592                            matching_targets
12593                                .iter()
12594                                .any(|target| Self::local_purge_values_match(target, values))
12595                        })
12596                    })
12597                })
12598                .cloned()
12599                .collect::<Vec<_>>();
12600            let doomed_ids = doomed
12601                .iter()
12602                .map(|commit| commit.client_commit_id.clone())
12603                .collect::<BTreeSet<_>>();
12604            let mut batch = ChangeAccumulator::default();
12605            for commit in &retained_dropped {
12606                batch.conflicts = true;
12607                batch.rejections = true;
12608                batch.outcomes = true;
12609                for operation in &commit.ops {
12610                    batch.table(&operation.table);
12611                }
12612            }
12613            let mut rejections = Vec::new();
12614            for commit in &doomed {
12615                for operation in &commit.ops {
12616                    batch.table(&operation.table);
12617                }
12618                let results = commit
12619                    .ops
12620                    .iter()
12621                    .enumerate()
12622                    .map(|(op_index, operation)| {
12623                        let rejection = RejectionRecord {
12624                            client_commit_id: commit.client_commit_id.clone(),
12625                            op_index: op_index as i32,
12626                            code: "client.local_data_purged".to_owned(),
12627                            message: "the commit was dropped by an application-authorized local data purge".to_owned(),
12628                            retryable: false,
12629                            details: None,
12630                            operation: Some(CommitOperation::from(operation)),
12631                        };
12632                        rejections.push(rejection.clone());
12633                        CommitOperationOutcome::Error { rejection }
12634                    })
12635                    .collect::<Vec<_>>();
12636                self.persist_commit_outcome(
12637                    &commit.client_commit_id,
12638                    CommitOutcomeStatus::Rejected,
12639                    &results,
12640                    Some(&commit.ops),
12641                )?;
12642                self.delete_outbox_persisted(&commit.client_commit_id)?;
12643            }
12644            if !doomed.is_empty() {
12645                self.outbox
12646                    .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
12647                self.rejections.extend(rejections);
12648                self.prune_commit_outcomes()?;
12649                batch.status = true;
12650                batch.rejections = true;
12651                batch.outcomes = true;
12652            }
12653
12654            let mut purged_rows = 0usize;
12655            for (table, ids) in &row_ids {
12656                if ids.is_empty() {
12657                    continue;
12658                }
12659                batch.table(table);
12660                for row_id in ids {
12661                    self.delete_base_row(table, row_id)?;
12662                    purged_rows += 1;
12663                }
12664            }
12665            self.rebuild_overlay_if_dirty();
12666            self.delete_unreferenced_cached_blobs();
12667            self.conn
12668                .execute(
12669                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
12670                    rusqlite::params![meta_key, canonical_plan],
12671                )
12672                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12673            Ok((
12674                batch,
12675                LocalDataPurgeResult {
12676                    already_applied: false,
12677                    purged_rows,
12678                    dropped_commits: doomed.len(),
12679                },
12680            ))
12681        })();
12682        let (batch, result) = match applied {
12683            Ok(value) => value,
12684            Err(error) => {
12685                self.rollback_observation("syncular_local_purge");
12686                self.outbox = prior_outbox;
12687                self.failed_commits = prior_failed;
12688                self.rejections = prior_rejections;
12689                self.conflicts = prior_conflicts;
12690                self.overlay_dirty.restore(prior_overlay_dirty.clone());
12691                return Err(error);
12692            }
12693        };
12694        if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
12695            self.rollback_observation("syncular_local_purge");
12696            self.outbox = prior_outbox;
12697            self.failed_commits = prior_failed;
12698            self.rejections = prior_rejections;
12699            self.conflicts = prior_conflicts;
12700            self.overlay_dirty.restore(prior_overlay_dirty.clone());
12701            return Err(error);
12702        }
12703        Ok(result)
12704    }
12705
12706    /// Application-authorized recovery of the replicated projection. Unlike a
12707    /// local security purge, this retains the entire outbox, device identity,
12708    /// lease, outcomes, subscription registrations, and protected bookkeeping.
12709    /// The projection reset, subscription rewind, optimistic replay, and
12710    /// idempotency marker share one savepoint for interruption safety.
12711    pub fn rebootstrap_local_data(
12712        &mut self,
12713        input: &LocalDataRebootstrapInput,
12714    ) -> Result<LocalDataRebootstrapResult, String> {
12715        if input.rebootstrap_id.is_empty()
12716            || input.rebootstrap_id.len() > 128
12717            || !is_local_operation_code_like(&input.rebootstrap_id)
12718        {
12719            return Err(
12720                "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
12721                    .to_owned(),
12722            );
12723        }
12724        let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
12725        if let Some(persisted) = self.get_meta_strict(&meta_key)? {
12726            let (retained_commits, reset_subscriptions) =
12727                decode_local_rebootstrap_receipt(&persisted)?;
12728            return Ok(LocalDataRebootstrapResult {
12729                already_applied: true,
12730                retained_commits,
12731                reset_subscriptions,
12732            });
12733        }
12734        if self.stopped || self.schema_floor.is_some() {
12735            return Err(
12736                "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
12737                    .to_owned(),
12738            );
12739        }
12740
12741        let retained_commits = self.outbox.len();
12742        let reset_subscriptions = self.subs.len();
12743        let prior_subs = self.subs.clone();
12744        let prior_upgrading = self.upgrading;
12745        let prior_stopped = self.stopped;
12746        let prior_schema_floor = self.schema_floor.clone();
12747        let prior_overlay_dirty = self.overlay_dirty.snapshot();
12748        let prior_sync_needed = self.sync_needed;
12749        let prior_sync_intents = self.sync_intent_queue.clone();
12750        let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
12751
12752        self.cancel_sync_round();
12753        self.begin_observation("syncular_local_rebootstrap")?;
12754        let mut batch = ChangeAccumulator::default();
12755        let applied = (|| -> Result<(), String> {
12756            self.run_schema_reset_observed(&mut batch, false, false)?;
12757            self.conn
12758                .execute(
12759                    "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
12760                    rusqlite::params![meta_key, receipt],
12761                )
12762                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12763            self.sync_needed = true;
12764            self.sync_intent_queue.push_back(SyncIntent::Interactive);
12765            batch.status = true;
12766            Ok(())
12767        })();
12768        if let Err(error) = applied {
12769            self.rollback_observation("syncular_local_rebootstrap");
12770            self.subs = prior_subs;
12771            self.upgrading = prior_upgrading;
12772            self.stopped = prior_stopped;
12773            self.schema_floor = prior_schema_floor;
12774            self.overlay_dirty.restore(prior_overlay_dirty.clone());
12775            self.sync_needed = prior_sync_needed;
12776            self.sync_intent_queue = prior_sync_intents;
12777            return Err(error);
12778        }
12779        if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
12780            self.rollback_observation("syncular_local_rebootstrap");
12781            self.subs = prior_subs;
12782            self.upgrading = prior_upgrading;
12783            self.stopped = prior_stopped;
12784            self.schema_floor = prior_schema_floor;
12785            self.overlay_dirty.restore(prior_overlay_dirty.clone());
12786            self.sync_needed = prior_sync_needed;
12787            self.sync_intent_queue = prior_sync_intents;
12788            return Err(error);
12789        }
12790
12791        Ok(LocalDataRebootstrapResult {
12792            already_applied: false,
12793            retained_commits,
12794            reset_subscriptions,
12795        })
12796    }
12797
12798    // -- scope purge + doomed outbox (§3.3) ----------------------------------------
12799
12800    /// Delete base rows matching the effective scopes; `Err(())` = no local
12801    /// scope-column mapping for a key (the fail-closed case).
12802    fn purge_scope_rows(
12803        &mut self,
12804        table_name: &str,
12805        effective: &[(String, Vec<String>)],
12806    ) -> Result<(), ()> {
12807        if effective.is_empty() {
12808            return Ok(());
12809        }
12810        let table = self.schema.table(table_name).ok_or(())?.clone();
12811        let mut clauses = Vec::new();
12812        let mut params: Vec<SqlValue> = Vec::new();
12813        for (variable, values) in effective {
12814            let column = table.scope_column(variable).ok_or(())?;
12815            let placeholders: Vec<String> = values
12816                .iter()
12817                .map(|v| {
12818                    params.push(SqlValue::Text(v.clone()));
12819                    "?".to_owned()
12820                })
12821                .collect();
12822            clauses.push(format!(
12823                "{} IN ({})",
12824                quote_ident(column),
12825                placeholders.join(", ")
12826            ));
12827        }
12828        let sql = format!(
12829            "DELETE FROM {} WHERE {}",
12830            base_table(table_name),
12831            clauses.join(" AND ")
12832        );
12833        self.overlay_dirty.table(table_name);
12834        self.conn
12835            .execute(&sql, rusqlite::params_from_iter(&params))
12836            .map_err(|_| ())?;
12837        self.conn
12838            .execute(
12839                &format!(
12840                    "DELETE FROM {} WHERE {}",
12841                    visible_table(table_name),
12842                    clauses.join(" AND ")
12843                ),
12844                rusqlite::params_from_iter(&params),
12845            )
12846            .map_err(|_| ())?;
12847        Ok(())
12848    }
12849
12850    /// §3.3: drop pending commits whose upserts provably land in the
12851    /// revoked effective scopes — whole-commit, never per-operation.
12852    fn drop_doomed_outbox(
12853        &mut self,
12854        table_name: &str,
12855        effective: &[(String, Vec<String>)],
12856    ) -> Result<bool, String> {
12857        if effective.is_empty() {
12858            return Ok(false);
12859        }
12860        let Some(table) = self.schema.table(table_name).cloned() else {
12861            return Ok(false);
12862        };
12863        let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
12864        for (variable, values) in effective {
12865            match table.scope_column(variable) {
12866                Some(column) => mappings.push((column, values)),
12867                None => return Ok(false), // not provable without a mapping
12868            }
12869        }
12870        let doomed: Vec<OutboxCommit> = self
12871            .outbox
12872            .iter()
12873            .filter(|commit| {
12874                commit.ops.iter().any(|op| {
12875                    op.upsert
12876                        && op.table == table_name
12877                        && op.values.as_ref().is_some_and(|values| {
12878                            mappings
12879                                .iter()
12880                                .all(|(column, allowed)| match values.get(*column) {
12881                                    Some(Value::String(s)) => allowed.contains(s),
12882                                    Some(Value::Number(n)) => allowed.contains(&n.to_string()),
12883                                    _ => false,
12884                                })
12885                        })
12886                })
12887            })
12888            .cloned()
12889            .collect();
12890        if doomed.is_empty() {
12891            return Ok(false);
12892        }
12893        let mut rejections = Vec::new();
12894        for commit in &doomed {
12895            let results = commit
12896                .ops
12897                .iter()
12898                .enumerate()
12899                .map(|(op_index, operation)| {
12900                    let rejection = RejectionRecord {
12901                        client_commit_id: commit.client_commit_id.clone(),
12902                        op_index: op_index as i32,
12903                        code: "sync.scope_revoked".to_owned(),
12904                        message: "the commit was dropped because its effective scope was revoked"
12905                            .to_owned(),
12906                        retryable: false,
12907                        details: None,
12908                        operation: Some(CommitOperation::from(operation)),
12909                    };
12910                    rejections.push(rejection.clone());
12911                    CommitOperationOutcome::Error { rejection }
12912                })
12913                .collect::<Vec<_>>();
12914            self.persist_commit_outcome(
12915                &commit.client_commit_id,
12916                CommitOutcomeStatus::Rejected,
12917                &results,
12918                Some(&commit.ops),
12919            )?;
12920            self.delete_outbox_persisted(&commit.client_commit_id)?;
12921        }
12922        self.prune_commit_outcomes()?;
12923        let doomed_ids = doomed
12924            .iter()
12925            .map(|commit| commit.client_commit_id.as_str())
12926            .collect::<BTreeSet<_>>();
12927        self.outbox
12928            .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
12929        self.rejections.extend(rejections);
12930        Ok(true)
12931    }
12932
12933    // -- blobs (§5.9) ----------------------------------------------------------------
12934
12935    /// §5.9.7: hash bytes into the content address, cache them, queue the
12936    /// upload (flushed before the next push, B4). Returns the canonical
12937    /// BlobRef JSON `{blobId, byteLength, mediaType?}` for a `blob_ref`
12938    /// column value.
12939    pub fn upload_blob(
12940        &mut self,
12941        bytes: &[u8],
12942        media_type: Option<String>,
12943        name: Option<String>,
12944    ) -> Result<Value, String> {
12945        let blob_id = blob_id_for(bytes);
12946        let now = self.clock_now_ms();
12947        let tx = self
12948            .conn
12949            .transaction()
12950            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12951        tx.execute(
12952            "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
12953             ON CONFLICT(blob_id) DO NOTHING",
12954            rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
12955        )
12956        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12957        tx.execute(
12958            "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
12959            rusqlite::params![blob_id, media_type, now],
12960        )
12961        .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12962        tx.commit()
12963            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12964        self.enforce_blob_cache_cap();
12965        let mut obj = Map::new();
12966        obj.insert("blobId".to_owned(), Value::from(blob_id));
12967        obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
12968        if let Some(mt) = media_type {
12969            obj.insert("mediaType".to_owned(), Value::from(mt));
12970        }
12971        if let Some(n) = name {
12972            obj.insert("name".to_owned(), Value::from(n));
12973        }
12974        Ok(Value::Object(obj))
12975    }
12976
12977    /// §5.9.7: resolve an owned blob body without driver encoding. A
12978    /// content-addressed cache hit serves with no fetch (B1); a miss downloads
12979    /// (§5.9.5), verifies the address, caches the bytes, and returns them.
12980    pub fn fetch_blob_bytes(
12981        &mut self,
12982        transport: &mut dyn Transport,
12983        blob_id_or_ref: &str,
12984    ) -> Result<FetchedBlob, (String, String)> {
12985        let simple = |m: String| ("client.failed".to_owned(), m);
12986        let blob_id = if blob_id_or_ref.starts_with("sha256:") {
12987            blob_id_or_ref.to_owned()
12988        } else {
12989            let value: Value = serde_json::from_str(blob_id_or_ref)
12990                .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
12991            value
12992                .get("blobId")
12993                .and_then(Value::as_str)
12994                .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
12995                .to_owned()
12996        };
12997        if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
12998            return Ok(cached);
12999        }
13000        // §5.9.5: propagate the server's blob.* code (blob.forbidden /
13001        // blob.not_found) verbatim so the harness can assert on it. The
13002        // authorized endpoint serves bytes inline OR (always-issue, presign
13003        // configured) a signed url the client fetches directly — no host auth,
13004        // no fall-through: failure => re-request (the caller's next fetch_blob_bytes).
13005        #[cfg(feature = "bench-internals")]
13006        let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
13007        let bytes = match transport
13008            .blob_download(&blob_id)
13009            .map_err(|e| (e.code, e.message))?
13010        {
13011            BlobDownload::Bytes(bytes) => bytes,
13012            BlobDownload::Url {
13013                url,
13014                url_expires_at_ms,
13015            } => {
13016                // §5.9.5: MUST NOT start a fetch at/past expiry.
13017                if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
13018                    return Err((
13019                        "sync.segment_expired".to_owned(),
13020                        format!(
13021                            "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
13022                        ),
13023                    ));
13024                }
13025                transport
13026                    .fetch_blob_url(&url)
13027                    .map_err(|e| (e.code, e.message))?
13028            }
13029        };
13030        #[cfg(feature = "bench-internals")]
13031        drop(download_phase);
13032        #[cfg(feature = "bench-internals")]
13033        let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
13034        // §5.9.5 inherits §5.1: verify the content address, reject mismatch.
13035        if blob_id_for(&bytes) != blob_id {
13036            return Err(simple(format!(
13037                "blob content address mismatch for {blob_id}"
13038            )));
13039        }
13040        #[cfg(feature = "bench-internals")]
13041        drop(validate_phase);
13042        #[cfg(feature = "bench-internals")]
13043        let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
13044        let now = self.clock_now_ms();
13045        self.conn
13046            .execute(
13047                "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
13048                rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
13049            )
13050            .map_err(|e| simple(e.to_string()))?;
13051        self.conn
13052            .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
13053            .map_err(|e| simple(e.to_string()))?;
13054        self.enforce_blob_cache_cap();
13055        #[cfg(feature = "bench-internals")]
13056        drop(insert_phase);
13057        self.get_cached_blob_bytes(&blob_id)
13058            .map_err(simple)?
13059            .ok_or_else(|| simple("blob cache write failed".to_owned()))
13060    }
13061
13062    fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
13063        #[cfg(feature = "bench-internals")]
13064        let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
13065        let mut stmt = self
13066            .conn
13067            .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
13068            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13069        let mut rows = stmt
13070            .query(rusqlite::params![blob_id])
13071            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13072        if let Some(row) = rows
13073            .next()
13074            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
13075        {
13076            let bytes: Vec<u8> = row
13077                .get(0)
13078                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13079            let byte_length: i64 = row
13080                .get(1)
13081                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13082            let media_type: Option<String> = row
13083                .get(2)
13084                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13085            return Ok(Some(FetchedBlob {
13086                blob_id: blob_id.to_owned(),
13087                byte_length,
13088                bytes,
13089                media_type,
13090            }));
13091        }
13092        Ok(None)
13093    }
13094
13095    /// §5.9.7 B4: upload every queued blob before push.
13096    fn pending_blob_uploads(&self) -> Result<Vec<crate::round::PendingUpload>, TransportError> {
13097        let pending: Vec<(String, Option<String>)> = {
13098            let mut stmt = self
13099                .conn
13100                .prepare(
13101                    "SELECT blob_id, media_type FROM (
13102                      SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
13103                      UNION ALL
13104                      SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
13105                      FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
13106                      WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
13107                    ) ORDER BY created_at_ms, blob_id",
13108                )
13109                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
13110            let rows = stmt
13111                .query_map([], |row| {
13112                    Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
13113                })
13114                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
13115            rows.collect::<Result<_, _>>()
13116                .map_err(|error| match error {
13117                    rusqlite::Error::InvalidColumnType(..)
13118                    | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
13119                        "sync.local_corrupt",
13120                        "Pending blob upload metadata is invalid",
13121                    ),
13122                    _ => TransportError::new("client.failed", error.to_string()),
13123                })?
13124        };
13125        let mut uploads = Vec::new();
13126        for (blob_id, media_type) in pending {
13127            let bytes = (|| {
13128                let stored: Option<(SqlValue, SqlValue)> = self
13129                    .conn
13130                    .query_row(
13131                        "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
13132                        rusqlite::params![blob_id],
13133                        |row| Ok((row.get(0)?, row.get(1)?)),
13134                    )
13135                    .optional()
13136                    .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
13137                let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
13138                    return Err(TransportError::new(
13139                        "sync.local_corrupt",
13140                        "Pending blob upload body is missing or corrupt",
13141                    ));
13142                };
13143                if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
13144                    return Err(TransportError::new(
13145                        "sync.local_corrupt",
13146                        "Pending blob upload body is missing or corrupt",
13147                    ));
13148                }
13149                Ok(bytes)
13150            })();
13151            let failed = bytes.is_err();
13152            uploads.push(crate::round::PendingUpload {
13153                id: blob_id,
13154                bytes,
13155                media_type,
13156            });
13157            if failed {
13158                break;
13159            }
13160        }
13161        Ok(uploads)
13162    }
13163
13164    #[cfg(test)]
13165    fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
13166        for upload in self.pending_blob_uploads()? {
13167            self.upload_one(
13168                transport,
13169                &upload.id,
13170                &upload.bytes?,
13171                upload.media_type.as_deref(),
13172            )?;
13173            self.conn
13174                .execute(
13175                    "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
13176                    rusqlite::params![upload.id],
13177                )
13178                .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
13179        }
13180        Ok(())
13181    }
13182
13183    /// §5.9.3: upload one blob, preferring the presigned direct-to-storage
13184    /// grant when the transport supports it, else streaming through the direct
13185    /// host-authenticated endpoint (capability, not fallback). A `Url` grant
13186    /// PUTs direct with no host auth; on a grant PUT failure the client streams
13187    /// through the direct endpoint — a *different, host-authenticated
13188    /// capability*, not a fall-through of the grant's authority.
13189    #[cfg(test)]
13190    fn upload_one(
13191        &self,
13192        transport: &mut dyn Transport,
13193        blob_id: &str,
13194        bytes: &[u8],
13195        media_type: Option<&str>,
13196    ) -> Result<(), TransportError> {
13197        crate::round::upload_one(transport, blob_id, bytes, media_type, self.now_ms)
13198    }
13199
13200    fn visible_blob_ids_sql(&self) -> String {
13201        let mut selects = Vec::new();
13202        for table in &self.schema.tables {
13203            for column in table
13204                .columns
13205                .iter()
13206                .filter(|column| column.ty == ColumnType::BlobRef)
13207            {
13208                let identifier = quote_ident(&column.name);
13209                selects.push(format!(
13210                    "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
13211                     WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
13212                     AND json_type({identifier}, '$.blobId') = 'text'",
13213                    quote_ident(&table.name),
13214                ));
13215            }
13216        }
13217        if selects.is_empty() {
13218            "SELECT NULL AS blob_id WHERE 0".to_owned()
13219        } else {
13220            selects.join(" UNION ")
13221        }
13222    }
13223
13224    /// §5.9.7 B1 size cap. Visible rows, pending commits, and
13225    /// staged uploads pin bodies directly; no derived refcount is stored.
13226    fn enforce_blob_cache_cap(&self) {
13227        let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
13228            return;
13229        };
13230        let mut total: i64 = self
13231            .conn
13232            .query_row(
13233                "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
13234                [],
13235                |row| row.get(0),
13236            )
13237            .unwrap_or(0);
13238        if total <= max_bytes {
13239            return;
13240        }
13241        let query = format!(
13242            "SELECT blob_id, byte_length FROM _syncular_blobs
13243             WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
13244               AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
13245               AND blob_id NOT IN ({})
13246             ORDER BY created_at_ms ASC, blob_id ASC",
13247            self.visible_blob_ids_sql(),
13248        );
13249        let candidates: Vec<(String, i64)> = {
13250            let Ok(mut stmt) = self.conn.prepare(&query) else {
13251                return;
13252            };
13253            let Ok(rows) = stmt.query_map([], |row| {
13254                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
13255            }) else {
13256                return;
13257            };
13258            rows.filter_map(Result::ok).collect()
13259        };
13260        for (blob_id, byte_length) in candidates {
13261            if total <= max_bytes {
13262                break;
13263            }
13264            let _ = self.conn.execute(
13265                "DELETE FROM _syncular_blobs WHERE blob_id = ?",
13266                rusqlite::params![blob_id],
13267            );
13268            total -= byte_length;
13269        }
13270    }
13271
13272    fn delete_unreferenced_cached_blobs(&mut self) {
13273        #[cfg(feature = "bench-internals")]
13274        let _phase = self.benchmark_phases.start(Phase::BlobRetention);
13275        if !self.schema_has_blobs() {
13276            return;
13277        }
13278        self.rebuild_overlay_if_dirty();
13279        let _ = self.conn.execute(
13280            &format!(
13281                "DELETE FROM _syncular_blobs
13282                 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
13283                   AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
13284                   AND blob_id NOT IN ({})",
13285                self.visible_blob_ids_sql(),
13286            ),
13287            [],
13288        );
13289    }
13290
13291    // -- local row storage ----------------------------------------------------------
13292
13293    /// The cached per-table primary-key upsert SQL for `full_table` (see
13294    /// the `insert_sql` field: built once, reused per row).
13295    fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
13296        if let Some(sql) = self.insert_sql.borrow().get(full_table) {
13297            return sql.clone();
13298        }
13299        let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
13300        columns.push(quote_ident("_syncular_version"));
13301        let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
13302        let primary_key = quote_ident(&table.primary_key);
13303        let updates = columns
13304            .iter()
13305            .filter(|column| **column != primary_key)
13306            .map(|column| format!("{column}=excluded.{column}"))
13307            .collect::<Vec<_>>()
13308            .join(", ");
13309        let sql = format!(
13310            "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
13311            columns.join(", "),
13312            placeholders.join(", ")
13313        );
13314        self.insert_sql
13315            .borrow_mut()
13316            .insert(full_table.to_owned(), sql.clone());
13317        sql
13318    }
13319
13320    fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
13321        self.overlay_dirty.table(table_name);
13322        self.write_row(&base_table(table_name), table_name, row, version)
13323    }
13324
13325    fn write_row(
13326        &self,
13327        full_table: &str,
13328        table_name: &str,
13329        row: &Row,
13330        version: i64,
13331    ) -> Result<(), String> {
13332        #[cfg(feature = "bench-internals")]
13333        let _phase = self.benchmark_phases.start(Phase::RowWrite);
13334        let table = self
13335            .schema
13336            .table(table_name)
13337            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
13338        let sql = self.insert_row_sql(full_table, table);
13339        let mut stmt = self
13340            .conn
13341            .prepare_cached(&sql)
13342            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13343        let params = row
13344            .iter()
13345            .map(RowParam::Cell)
13346            .chain(std::iter::once(RowParam::Version(version)));
13347        stmt.execute(rusqlite::params_from_iter(params))
13348            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13349        Ok(())
13350    }
13351
13352    fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
13353        let table = self
13354            .schema
13355            .table(table_name)
13356            .ok_or_else(|| format!("unknown table {table_name:?}"))?;
13357        self.overlay_dirty.table(table_name);
13358        let sql = format!(
13359            "DELETE FROM {} WHERE {}",
13360            base_table(table_name),
13361            row_id_predicate(table)
13362        );
13363        let mut stmt = self
13364            .conn
13365            .prepare_cached(&sql)
13366            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13367        stmt.execute(rusqlite::params![row_id])
13368            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13369        Ok(())
13370    }
13371
13372    fn has_acknowledged_rows(&self) -> bool {
13373        self.conn
13374            .query_row(
13375                "SELECT EXISTS(SELECT 1 FROM _syncular_acked_rows)",
13376                [],
13377                |row| row.get(0),
13378            )
13379            .expect("read acknowledged intent")
13380    }
13381
13382    fn acknowledged_operations(
13383        &self,
13384        rows: Option<&HashSet<(&str, &str)>>,
13385    ) -> Result<Vec<OutboxOp>, String> {
13386        let keys = rows.map(|rows| {
13387            serde_json::to_string(
13388                &rows
13389                    .iter()
13390                    .map(|(table, id)| serde_json::to_string(&(table, id)).expect("row key"))
13391                    .collect::<Vec<_>>(),
13392            )
13393            .expect("row keys")
13394        });
13395        let mut statement = self
13396            .conn
13397            .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")
13398            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13399        let rows = statement
13400            .query_map([keys], |row| {
13401                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
13402            })
13403            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13404        rows.map(|row| {
13405            let (op, intent) =
13406                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13407            let op: CommitOperation =
13408                serde_json::from_str(&op).map_err(|_| "sync.local_corrupt".to_owned())?;
13409            let table = self
13410                .schema
13411                .table(&op.table)
13412                .ok_or_else(|| "sync.unknown_table".to_owned())?;
13413            let values = if op.op == "upsert" && self.visible_row(table, &op.row_id).is_none() {
13414                Some(serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?)
13415            } else {
13416                op.values
13417            };
13418            Ok(OutboxOp {
13419                table: op.table,
13420                row_id: op.row_id,
13421                upsert: op.op == "upsert",
13422                base_version: op.base_version,
13423                values,
13424            })
13425        })
13426        .collect()
13427    }
13428
13429    fn drop_acknowledged_rows(
13430        &self,
13431        matches: impl Fn(&str, &Map<String, Value>) -> bool,
13432    ) -> Result<Vec<OutboxCommit>, String> {
13433        let mut statement = self
13434            .conn
13435            .prepare("SELECT commit_id,tbl,intent_json,id FROM _syncular_acked_rows")
13436            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13437        let rows = statement
13438            .query_map([], |row| {
13439                Ok((
13440                    row.get::<_, String>(0)?,
13441                    row.get::<_, String>(1)?,
13442                    row.get::<_, String>(2)?,
13443                    row.get::<_, String>(3)?,
13444                ))
13445            })
13446            .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13447        let mut doomed = HashSet::new();
13448        for row in rows {
13449            let (id, table, intent, row_id) =
13450                row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13451            let values =
13452                serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?;
13453            let base_matches = self
13454                .schema
13455                .table(&table)
13456                .and_then(|schema| self.stored_row(schema, &row_id, true))
13457                .is_some_and(|(base, _)| {
13458                    let schema = self.schema.table(&table).expect("acknowledged table");
13459                    let values = schema
13460                        .columns
13461                        .iter()
13462                        .zip(base.iter())
13463                        .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
13464                        .collect();
13465                    matches(&table, &values)
13466                });
13467            if matches(&table, &values) || base_matches {
13468                doomed.insert(id);
13469            }
13470        }
13471        let mut dropped = Vec::new();
13472        for id in doomed {
13473            let mut statement = self
13474                .conn
13475                .prepare("SELECT op_json FROM _syncular_acked_rows WHERE commit_id=?1 ORDER BY idx")
13476                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13477            let operations = statement
13478                .query_map([&id], |row| row.get::<_, String>(0))
13479                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13480            let mut ops = Vec::new();
13481            for operation in operations {
13482                let json = operation
13483                    .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13484                let op: CommitOperation =
13485                    serde_json::from_str(&json).map_err(|_| "sync.local_corrupt".to_owned())?;
13486                self.overlay_dirty.table(&op.table);
13487                ops.push(OutboxOp {
13488                    table: op.table,
13489                    row_id: op.row_id,
13490                    upsert: op.op == "upsert",
13491                    base_version: op.base_version,
13492                    values: op.values,
13493                });
13494            }
13495            dropped.push(OutboxCommit {
13496                client_commit_id: id.clone(),
13497                ops,
13498            });
13499            self.conn
13500                .execute("DELETE FROM _syncular_acked_rows WHERE commit_id=?1", [id])
13501                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13502        }
13503        Ok(dropped)
13504    }
13505
13506    /// [`Self::rebuild_overlay`], skipped when neither the base tables nor
13507    /// the outbox changed since the last rebuild (the no-op sync round).
13508    fn rebuild_overlay_if_dirty(&mut self) {
13509        if self.overlay_dirty.get() {
13510            self.rebuild_overlay();
13511        }
13512    }
13513
13514    /// §7.1: local reads see outbox state applied optimistically — rebuild
13515    /// every visible table as (base server state) + (pending outbox replay
13516    /// on top). Optimistic rows carry version `-1`.
13517    fn rebuild_overlay(&mut self) {
13518        #[cfg(feature = "bench-internals")]
13519        let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
13520        #[cfg(test)]
13521        self.overlay_rebuild_count
13522            .set(self.overlay_rebuild_count.get() + 1);
13523        let dirty = self.overlay_dirty.snapshot();
13524        let tables: Vec<_> = self
13525            .schema
13526            .tables
13527            .iter()
13528            .filter(|table| {
13529                dirty
13530                    .as_ref()
13531                    .is_none_or(|names| names.is_empty() || names.contains(&table.name))
13532            })
13533            .cloned()
13534            .collect();
13535        let names: BTreeSet<_> = tables.iter().map(|table| table.name.as_str()).collect();
13536        self.exec("SAVEPOINT syncular_overlay");
13537        for table in &tables {
13538            for index in &table.fts_indexes {
13539                let _ = self.drop_fts_triggers(index);
13540            }
13541            let visible = visible_table(&table.name);
13542            let base = base_table(&table.name);
13543            self.exec(&format!("DELETE FROM {visible}"));
13544            self.exec(&format!("INSERT INTO {visible} SELECT * FROM {base}"));
13545        }
13546        let acknowledged = self
13547            .acknowledged_operations(None)
13548            .expect("decode acknowledged intent");
13549        self.apply_outbox_ops(
13550            acknowledged
13551                .iter()
13552                .filter(|op| names.contains(op.table.as_str())),
13553        );
13554        for (commit, initial) in &self.failed_commits {
13555            for operation in &commit.ops {
13556                if !names.contains(operation.table.as_str()) || !operation.upsert {
13557                    continue;
13558                }
13559                if let Some(table) = self.schema.table(&operation.table) {
13560                    if self.visible_row(table, &operation.row_id).is_none()
13561                        && operation.values.as_ref().is_some_and(|values| {
13562                            table
13563                                .columns
13564                                .iter()
13565                                .all(|column| values.contains_key(&column.name))
13566                        })
13567                    {
13568                        let key = serde_json::to_string(&(
13569                            operation.table.clone(),
13570                            operation.row_id.clone(),
13571                        ))
13572                        .expect("row key serialization");
13573                        if let Some(Value::Object(values)) = initial.get(&key) {
13574                            self.apply_outbox_ops(&[OutboxOp {
13575                                values: Some(values.clone()),
13576                                ..operation.clone()
13577                            }]);
13578                        }
13579                    }
13580                }
13581            }
13582            self.apply_outbox_ops(
13583                commit
13584                    .ops
13585                    .iter()
13586                    .filter(|op| names.contains(op.table.as_str())),
13587            );
13588        }
13589        for commit in &self.outbox {
13590            self.apply_outbox_ops(
13591                commit
13592                    .ops
13593                    .iter()
13594                    .filter(|op| names.contains(op.table.as_str())),
13595            );
13596        }
13597        for table in &tables {
13598            for index in &table.fts_indexes {
13599                let _ = self.rebuild_fts_projection(table, index);
13600                let _ = self.create_fts_triggers(table, index);
13601            }
13602        }
13603        self.exec("RELEASE syncular_overlay");
13604        self.overlay_dirty.set(false);
13605    }
13606
13607    /// §7.1 replay: every operation is applied over the current visible
13608    /// state. An operation that no longer applies (a server row now holds its
13609    /// unique value) stays pending and invisible until a later replay admits
13610    /// it or the server answers its push.
13611    fn apply_outbox_ops<'a>(&self, ops: impl IntoIterator<Item = &'a OutboxOp>) {
13612        #[cfg(feature = "bench-internals")]
13613        let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
13614        for op in ops {
13615            #[cfg(feature = "bench-internals")]
13616            if let Some(phase) = &mut phase {
13617                phase.unit();
13618            }
13619            if op.upsert {
13620                if let (Some(table), Some(values)) = (
13621                    self.schema
13622                        .table(&op.table)
13623                        .filter(|table| table.indexes.iter().any(|index| index.unique)),
13624                    op.values.as_ref(),
13625                ) {
13626                    let mut intended: Map<String, Value> = self
13627                        .visible_row(table, &op.row_id)
13628                        .map(|(row, _)| {
13629                            table
13630                                .columns
13631                                .iter()
13632                                .zip(&row)
13633                                .map(|(column, value)| {
13634                                    (column.name.clone(), column_value_to_json(value))
13635                                })
13636                                .collect()
13637                        })
13638                        .unwrap_or_default();
13639                    intended.extend(values.clone());
13640                    if !self
13641                        .unique_conflicts(table, &intended, false)
13642                        .expect("overlay unique-key read failed")
13643                        .is_empty()
13644                    {
13645                        continue;
13646                    }
13647                }
13648            }
13649            let _ = self.apply_outbox_op(op);
13650        }
13651    }
13652
13653    fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
13654        let Some(table) = self.schema.table(&op.table) else {
13655            return Ok(());
13656        };
13657        let visible = visible_table(&table.name);
13658        if !op.upsert {
13659            let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
13660            self.conn
13661                .prepare_cached(&sql)
13662                .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
13663                .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13664            return Ok(());
13665        }
13666        let Some(values) = op.values.as_ref() else {
13667            return Ok(());
13668        };
13669        // §7.1: the overlay applies the operation's PRESENT columns
13670        // over the current local row. A partial operation over an
13671        // absent local row leaves it absent — the server answers it
13672        // with sync.row_deleted or sync.row_missing (§6.2).
13673        let full = table
13674            .columns
13675            .iter()
13676            .all(|column| values.contains_key(&column.name));
13677        let local = self.visible_row(table, &op.row_id);
13678        if local.is_none() && !full {
13679            return Ok(());
13680        }
13681        let mut row: Row = Vec::with_capacity(table.columns.len());
13682        for (index, column) in table.columns.iter().enumerate() {
13683            let incoming = match values.get(&column.name) {
13684                Some(raw) => json_to_column_value(column, Some(raw)),
13685                None => Ok(local
13686                    .as_ref()
13687                    .and_then(|(values, _)| values.get(index).cloned())
13688                    .flatten()),
13689            };
13690            match incoming {
13691                Ok(value) => row.push(value),
13692                Err(_) => return Ok(()),
13693            }
13694        }
13695        let version = local.as_ref().map_or(-1, |(_, version)| *version);
13696        self.write_row(&visible, &table.name, &row, version)
13697            .map_err(|error| {
13698                if error.starts_with("UNIQUE constraint failed:") {
13699                    "sync.constraint_violation: local write violates a unique constraint".to_owned()
13700                } else {
13701                    error
13702                }
13703            })
13704    }
13705
13706    /// The visible (optimistic) row's values plus version, or `None` when the
13707    /// row is absent from the overlay.
13708    fn visible_row(&self, table: &TableSchema, row_id: &str) -> Option<(Row, i64)> {
13709        self.stored_row(table, row_id, false)
13710    }
13711
13712    fn stored_row(&self, table: &TableSchema, row_id: &str, base: bool) -> Option<(Row, i64)> {
13713        let sql = format!(
13714            "SELECT * FROM {} WHERE {}",
13715            if base {
13716                base_table(&table.name)
13717            } else {
13718                visible_table(&table.name)
13719            },
13720            row_id_predicate(table)
13721        );
13722        let mut stmt = self.conn.prepare_cached(&sql).ok()?;
13723        let mut rows = stmt.query(rusqlite::params![row_id]).ok()?;
13724        let row = rows.next().ok()??;
13725        let mut values = Vec::with_capacity(table.columns.len());
13726        for (index, column) in table.columns.iter().enumerate() {
13727            let raw = row.get_ref(index).ok()?;
13728            let json = sql_ref_to_json(column, raw);
13729            values.push(json_to_column_value(column, Some(&json)).ok()?);
13730        }
13731        let version: i64 = row.get(table.columns.len()).ok()?;
13732        Some((values, version))
13733    }
13734
13735    fn exec(&self, sql: &str) {
13736        let _ = self.conn.execute_batch(sql);
13737    }
13738
13739    // -- realtime (§8) ---------------------------------------------------------------
13740
13741    /// §8.8: the configured realtime policy.
13742    #[must_use]
13743    pub fn realtime_policy(&self) -> RealtimePolicy {
13744        self.realtime_policy
13745    }
13746
13747    /// §8.8: the explicit realtime availability state.
13748    #[must_use]
13749    pub fn realtime_state(&self) -> RealtimeState {
13750        self.realtime_state
13751    }
13752
13753    /// §8.8: set the realtime policy. Refused while the socket is connected:
13754    /// a policy change never silently re-routes an ownership state.
13755    pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
13756        if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
13757            return Err(
13758                "sync.invalid_request: disconnect realtime before changing the realtime policy"
13759                    .to_owned(),
13760            );
13761        }
13762        self.realtime_policy = policy;
13763        match policy {
13764            RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
13765            RealtimePolicy::Required | RealtimePolicy::Optional
13766                if self.realtime_state == RealtimeState::Disabled =>
13767            {
13768                self.set_realtime_state(RealtimeState::Disconnected, None);
13769            }
13770            RealtimePolicy::Required | RealtimePolicy::Optional => {}
13771        }
13772        Ok(())
13773    }
13774
13775    /// Record an availability transition, keeping the reason code bounded and
13776    /// code-like (§7.6) and clearing it where it cannot apply.
13777    fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
13778        self.realtime_state = state;
13779        self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
13780        if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
13781            self.realtime_reason_code = None;
13782            self.realtime_retry_delay_ms = None;
13783        }
13784    }
13785
13786    #[must_use]
13787    fn realtime_connected(&self) -> bool {
13788        self.realtime_state == RealtimeState::Connected
13789    }
13790
13791    pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
13792        if self.realtime_policy == RealtimePolicy::Off {
13793            return Err(
13794                "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
13795            );
13796        }
13797        if self.realtime_connected() {
13798            return Ok(());
13799        }
13800        self.realtime_state = RealtimeState::Connecting;
13801        match transport.realtime_connect_for_client(&self.client_id) {
13802            Ok(()) => {
13803                self.set_realtime_state(RealtimeState::Connected, None);
13804                Ok(())
13805            }
13806            Err(error) => {
13807                self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
13808                Err(format!("{}: {}", error.code, error.message))
13809            }
13810        }
13811    }
13812
13813    pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
13814        self.cancel_sync_round();
13815        // A deliberately disconnected or policy-disabled client has nothing
13816        // to release; a `lost` socket still does (the host owns the handle).
13817        if self.realtime_state == RealtimeState::Disconnected
13818            || self.realtime_state == RealtimeState::Disabled
13819        {
13820            return;
13821        }
13822        let _ = transport.realtime_close();
13823        self.set_realtime_state(
13824            if self.realtime_policy == RealtimePolicy::Off {
13825                RealtimeState::Disabled
13826            } else {
13827                RealtimeState::Disconnected
13828            },
13829            None,
13830        );
13831        self.presence.clear(); // §8.6.1: presence is per-connection
13832    }
13833
13834    /// §8.6.2: publish (or clear, `doc: None`) this client's presence
13835    /// document for `scope_key`. Requires a live socket; the document is
13836    /// ephemeral (lost on disconnect). Authorization is the connection's
13837    /// registration (§8.6.3) — an unheld key is rejected loudly by the
13838    /// server with `presence.forbidden`.
13839    pub fn set_presence(
13840        &mut self,
13841        transport: &mut dyn Transport,
13842        scope_key: &str,
13843        doc: Option<&Value>,
13844    ) -> Result<(), String> {
13845        if !self.realtime_connected() {
13846            return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
13847        }
13848        let text = encode_presence_publish(scope_key, doc);
13849        transport
13850            .realtime_send(&text)
13851            .map_err(|e| format!("{}: {}", e.code, e.message))
13852    }
13853
13854    /// §8.6: the peers currently present on a scope key (ephemeral).
13855    pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
13856        self.presence
13857            .get(scope_key)
13858            .map(|peers| peers.values().cloned().collect())
13859            .unwrap_or_default()
13860    }
13861
13862    /// §8.6 apply an inbound presence fanout to the local map.
13863    fn apply_presence(
13864        &mut self,
13865        scope_key: String,
13866        kind: Option<PresenceKind>,
13867        actor_id: Option<String>,
13868        client_id: Option<String>,
13869        doc: Option<Value>,
13870        error: Option<String>,
13871    ) {
13872        // The publisher-directed error variant is out-of-band; nothing to
13873        // record in the peer map.
13874        if error.is_some() {
13875            return;
13876        }
13877        let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
13878            return;
13879        };
13880        let peer_key = format!("{actor_id} {client_id}");
13881        match kind {
13882            PresenceKind::Leave => {
13883                if let Some(peers) = self.presence.get_mut(&scope_key) {
13884                    peers.remove(&peer_key);
13885                    if peers.is_empty() {
13886                        self.presence.remove(&scope_key);
13887                    }
13888                }
13889            }
13890            _ => {
13891                let doc = match doc {
13892                    Some(Value::Object(_)) => doc.unwrap(),
13893                    _ => return,
13894                };
13895                self.presence.entry(scope_key).or_default().insert(
13896                    peer_key,
13897                    PresencePeer {
13898                        actor_id,
13899                        client_id,
13900                        doc,
13901                    },
13902                );
13903            }
13904        }
13905    }
13906
13907    /// Inbound JSON control message (§8.1). Unknown events are tolerated.
13908    pub fn on_realtime_text(&mut self, text: &str) {
13909        match parse_control(text) {
13910            Ok(ControlMessage::Hello { requires_sync, .. }) => {
13911                if requires_sync {
13912                    // §8.1: pull before trusting the socket for continuity.
13913                    self.set_sync_needed(true, true);
13914                }
13915            }
13916            Ok(ControlMessage::Presence {
13917                scope_key,
13918                kind,
13919                actor_id,
13920                client_id,
13921                doc,
13922                error,
13923                ..
13924            }) => {
13925                self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
13926            }
13927            Ok(ControlMessage::Wake { .. }) => {
13928                // §8.3: any wake-up means "run a pull soon", never data.
13929                self.set_sync_needed(true, true);
13930            }
13931            _ => {}
13932        }
13933    }
13934
13935    /// Inbound binary delta: a complete SSP2 response (§8.2), applied like
13936    /// a pull response per section; an unapplied delta is a wake-up.
13937    /// Mailbox hosts send durable acknowledgements on their I/O executor.
13938    pub fn on_realtime_binary_queued(&mut self, bytes: &[u8]) -> Vec<String> {
13939        let mut transport = crate::round::DownloadResults::default();
13940        self.on_realtime_binary(&mut transport, bytes);
13941        transport.controls
13942    }
13943
13944    pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
13945        if self.stopped {
13946            return;
13947        }
13948        #[cfg(feature = "bench-internals")]
13949        let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
13950        let message = match decode_message(bytes) {
13951            Ok(m) if m.msg_kind == MsgKind::Response => m,
13952            _ => {
13953                self.set_sync_needed(true, true);
13954                return;
13955            }
13956        };
13957        #[cfg(feature = "bench-internals")]
13958        drop(decode_phase);
13959        let mut frames = message.frames.into_iter();
13960        let mut applied_cursor: Option<i64> = None;
13961        let mut any_covered = false;
13962        let mut dropped = false;
13963        while let Some(frame) = frames.next() {
13964            let Frame::SubStart {
13965                id,
13966                status,
13967                effective_scopes,
13968                ..
13969            } = frame
13970            else {
13971                continue;
13972            };
13973            let mut body = Vec::new();
13974            let mut next_cursor: Option<i64> = None;
13975            for inner in frames.by_ref() {
13976                match inner {
13977                    Frame::SubEnd {
13978                        next_cursor: nc, ..
13979                    } => {
13980                        next_cursor = Some(nc);
13981                        break;
13982                    }
13983                    Frame::Unknown { .. } => {}
13984                    other => body.push(other),
13985                }
13986            }
13987            let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
13988                dropped = true;
13989                continue;
13990            };
13991            let sub = &self.subs[sub_index];
13992            // §8.2: only locally active, not mid-bootstrap subscriptions
13993            // apply; skipped sections are repaired by the next pull.
13994            if status != SubStatus::Active
13995                || sub.state != SubState::Active
13996                || sub.bootstrap_state.is_some()
13997                || !sub.synced_once
13998            {
13999                dropped = true;
14000                continue;
14001            }
14002            if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
14003                // Idempotent redelivery of an already-covered window.
14004                any_covered = true;
14005                continue;
14006            }
14007            let previous_effective = self.subs[sub_index].effective.clone();
14008            let previous_cursor = self.subs[sub_index].cursor;
14009            self.subs[sub_index].effective = Some(effective_scopes);
14010            let mut failed = false;
14011            for inner in body {
14012                match inner {
14013                    Frame::Commit {
14014                        tables,
14015                        changes,
14016                        commit_seq,
14017                        ..
14018                    } => {
14019                        if self
14020                            .apply_commit_frame(&tables, &changes, Some(commit_seq))
14021                            .is_err()
14022                        {
14023                            failed = true;
14024                            break;
14025                        }
14026                    }
14027                    _ => {
14028                        failed = true;
14029                        break;
14030                    }
14031                }
14032            }
14033            let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
14034                self.apply_sub_end(sub_index, cursor, None)
14035                    .ok()
14036                    .map(|()| cursor)
14037            });
14038            let Some(next_cursor) = committed else {
14039                self.subs[sub_index].effective = previous_effective;
14040                self.subs[sub_index].cursor = previous_cursor;
14041                dropped = true;
14042                continue;
14043            };
14044            applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
14045        }
14046        if let Some(cursor) = applied_cursor {
14047            // §8.2 ack point: the highest applied SUB_END.nextCursor.
14048            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
14049            let _ = transport.realtime_send(&ack);
14050        } else if !any_covered || dropped {
14051            // §8.2: a delta not applied at all is treated as a wake-up.
14052            self.set_sync_needed(true, true);
14053        }
14054    }
14055
14056    /// §8.2 ack point after an HTTP pull on a live connection: the minimum
14057    /// cursor across active, non-bootstrapping subscriptions that have
14058    /// synced at least once. No such subscription, no ack.
14059    fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
14060        if !self.realtime_connected() {
14061            return;
14062        }
14063        let floor = self
14064            .subs
14065            .iter()
14066            .filter(|s| {
14067                s.state == SubState::Active
14068                    && s.bootstrap_state.is_none()
14069                    && s.synced_once
14070                    && s.cursor >= 0
14071            })
14072            .map(|s| s.cursor)
14073            .min();
14074        if let Some(cursor) = floor {
14075            let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
14076            let _ = transport.realtime_send(&ack);
14077        }
14078    }
14079}
14080
14081#[cfg(test)]
14082mod previous_version_wiring_tests {
14083    use super::*;
14084    use serde_json::{json, Value};
14085
14086    fn previous_version_schema(version: i32) -> Value {
14087        json!({
14088            "version": version,
14089            "tables": [{
14090                "name": "tasks",
14091                "primaryKey": "id",
14092                "columns": [
14093                    { "name": "id", "type": "string", "nullable": false },
14094                    { "name": "note", "type": "string", "nullable": true }
14095                ],
14096                "scopes": []
14097            }]
14098        })
14099    }
14100
14101    fn previous_version_limits() -> crate::api::ClientLimits {
14102        crate::api::ClientLimits {
14103            previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
14104                enabled: true,
14105                ..Default::default()
14106            }),
14107            ..Default::default()
14108        }
14109    }
14110
14111    /// The configuration the live client carries, for a direct capture call.
14112    fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
14113        previous_version_limits()
14114            .previous_version_context
14115            .expect("configured")
14116    }
14117
14118    /// Capture the container the way a bump would, so a read test starts from a
14119    /// container this client's own state will then decide about.
14120    fn recapture_previous_version(client: &SyncClient) {
14121        let replica_path = client
14122            .previous_version_replica_path()
14123            .expect("file-backed replica");
14124        let outcome = crate::previous_version::capture_previous_version_from_replica(
14125            &client.conn,
14126            &replica_path,
14127            2,
14128            2,
14129            &previous_version_config(),
14130            1,
14131        )
14132        .expect("capture");
14133        assert!(matches!(
14134            outcome,
14135            crate::previous_version::CaptureOutcome::Captured(_)
14136        ));
14137    }
14138
14139    fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
14140        Subscription {
14141            id: "wiring-sub".to_owned(),
14142            table: "tasks".to_owned(),
14143            requested: Vec::new(),
14144            params: None,
14145            cursor,
14146            bootstrap_state: bootstrap_state.map(str::to_owned),
14147            state,
14148            reason_code: None,
14149            effective: None,
14150            synced_once: false,
14151        }
14152    }
14153
14154    #[test]
14155    fn previous_version_read_reasons_come_from_live_client_state() {
14156        use crate::previous_version::{
14157            PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
14158        };
14159        let path = std::env::temp_dir()
14160            .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
14161            .to_string_lossy()
14162            .into_owned();
14163        let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
14164        {
14165            let mut setup = SyncClient::open_path(
14166                "wiring".to_owned(),
14167                &previous_version_schema(1),
14168                previous_version_limits(),
14169                &path,
14170            )
14171            .expect("v1 install");
14172            setup
14173                .mutate(vec![Mutation::Upsert {
14174                    table: "tasks".to_owned(),
14175                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
14176                    base_version: None,
14177                }])
14178                .expect("seed a row");
14179        }
14180        let mut client = SyncClient::open_path(
14181            "wiring".to_owned(),
14182            &previous_version_schema(2),
14183            previous_version_limits(),
14184            &path,
14185        )
14186        .expect("v2 bump captures");
14187        assert!(std::path::Path::new(&container).exists());
14188        let spec = PreviousVersionReadSpec {
14189            table: "tasks".to_owned(),
14190            ..Default::default()
14191        };
14192
14193        // §7.3.5 lease error ⇒ lease-inactive, and the read discards.
14194        recapture_previous_version(&client);
14195        client.lease_state = Some(LeaseState {
14196            lease_id: None,
14197            expires_at_ms: None,
14198            error_code: Some("lease.expired".to_owned()),
14199        });
14200        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
14201        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
14202        assert!(!snapshot.available);
14203        assert!(!std::path::Path::new(&container).exists());
14204
14205        // An expired lease uses the client clock (injected, no sleeps).
14206        recapture_previous_version(&client);
14207        client.set_now_ms(1_000);
14208        client.lease_state = Some(LeaseState {
14209            lease_id: Some("lease".to_owned()),
14210            expires_at_ms: Some(1_000),
14211            error_code: None,
14212        });
14213        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
14214        assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
14215        assert!(!std::path::Path::new(&container).exists());
14216        client.lease_state = None;
14217
14218        // A revoked subscription ⇒ scope-revoked.
14219        recapture_previous_version(&client);
14220        client.subs.push(subscription(SubState::Revoked, 0, None));
14221        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
14222        assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
14223        assert!(!std::path::Path::new(&container).exists());
14224        client.subs.clear();
14225
14226        // Coverage completion: every ACTIVE subscription has a cursor and no
14227        // resume token — a still-resuming subscription is not completion.
14228        recapture_previous_version(&client);
14229        client
14230            .subs
14231            .push(subscription(SubState::Active, 0, Some("resume")));
14232        assert!(!client.previous_version_coverage_complete());
14233        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
14234        assert!(snapshot.available);
14235        assert_eq!(snapshot.previous_version, Some(2));
14236        assert_eq!(snapshot.rows.len(), 1);
14237
14238        recapture_previous_version(&client);
14239        client.subs[0].bootstrap_state = None;
14240        assert!(client.previous_version_coverage_complete());
14241        let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
14242        assert_eq!(
14243            snapshot.reason,
14244            Some(PreviousVersionReason::CoverageComplete)
14245        );
14246        assert!(!std::path::Path::new(&container).exists());
14247        client.subs.clear();
14248
14249        // With no active subscription at all, coverage is NOT complete.
14250        assert!(!client.previous_version_coverage_complete());
14251
14252        drop(client);
14253        let _ = std::fs::remove_file(&path);
14254    }
14255
14256    /// RFC 0005 D7 lifetime drops. The read surface already proves the reasons
14257    /// come from live state; this proves the OTHER caller —
14258    /// `previous_version_lifetime_check`, the once-per-sync-round trigger —
14259    /// actually removes the container for lease-inactivity, coverage
14260    /// completion and scope revocation, and leaves it alone while the
14261    /// replacement bootstrap is still resuming.
14262    #[test]
14263    fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
14264        let path = std::env::temp_dir()
14265            .join(format!(
14266                "syncular-prev-lifetime-{}.db",
14267                uuid::Uuid::new_v4()
14268            ))
14269            .to_string_lossy()
14270            .into_owned();
14271        let container = format!(
14272            "{path}{}",
14273            crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
14274        );
14275        let exists = || std::path::Path::new(&container).exists();
14276        {
14277            let mut setup = SyncClient::open_path(
14278                "lifetime".to_owned(),
14279                &previous_version_schema(1),
14280                previous_version_limits(),
14281                &path,
14282            )
14283            .expect("v1 install");
14284            setup
14285                .mutate(vec![Mutation::Upsert {
14286                    table: "tasks".to_owned(),
14287                    values: Map::from_iter([("id".to_owned(), json!("t1"))]),
14288                    base_version: None,
14289                }])
14290                .expect("seed a row");
14291        }
14292        let mut client = SyncClient::open_path(
14293            "lifetime".to_owned(),
14294            &previous_version_schema(2),
14295            previous_version_limits(),
14296            &path,
14297        )
14298        .expect("v2 bump captures");
14299        // Pin the clock so the 24h TTL never fires in this test (the TTL has
14300        // its own coverage); the capture record's createdAtMs is 1.
14301        client.set_now_ms(1_000);
14302        assert!(exists());
14303
14304        // §7.3.5 lease error ⇒ the lifetime check drops the container.
14305        client.lease_state = Some(LeaseState {
14306            lease_id: None,
14307            expires_at_ms: None,
14308            error_code: Some("lease.expired".to_owned()),
14309        });
14310        client.previous_version_lifetime_check();
14311        assert!(!exists());
14312        client.lease_state = None;
14313
14314        // §7.4.5 coverage completion (every active sub has a cursor and no
14315        // resume token) ⇒ drop.
14316        recapture_previous_version(&client);
14317        client.subs.push(subscription(SubState::Active, 0, None));
14318        client.previous_version_lifetime_check();
14319        assert!(!exists());
14320        client.subs.clear();
14321
14322        // §3.3 scope revocation ⇒ drop.
14323        recapture_previous_version(&client);
14324        client.subs.push(subscription(SubState::Revoked, 0, None));
14325        client.previous_version_lifetime_check();
14326        assert!(!exists());
14327        client.subs.clear();
14328
14329        // Negative control: a still-resuming active subscription is NOT
14330        // coverage completion, so the container survives the same call.
14331        recapture_previous_version(&client);
14332        client
14333            .subs
14334            .push(subscription(SubState::Active, 0, Some("resume")));
14335        client.previous_version_lifetime_check();
14336        assert!(exists());
14337        client.subs.clear();
14338
14339        drop(client);
14340        let _ = std::fs::remove_file(&path);
14341        let _ = std::fs::remove_file(&container);
14342        let _ = std::fs::remove_file(format!("{container}-wal"));
14343        let _ = std::fs::remove_file(format!("{container}-shm"));
14344    }
14345}