Skip to main content

max / makenotwork

synckit-client: land SyncStore higher-level engine behind default-on `store` feature Absorbs the SQLite plumbing the first-party apps otherwise hand-write: schema-declared changelog + triggers, FK-ordered apply, HLC ledger and conflict dispatch, push/pull loops, blob engine, and the scheduler, all driven by a declarative SyncSchema. One column whitelist generates the triggers, snapshot projection, and apply-side binding, killing the column-list triplication that silently NULLed columns on drift. Gated on the default-on `store` feature (rusqlite, bundled); mnw-cli opts out via default-features = false and skips bundled SQLite. Reconcile architecture.md, which still framed the layer as an unimplemented proposal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-12 20:56 UTC
Commit: 5b3cd290035b8307a75c98d2f98364ef6dfaf2a2
Parent: 9855328
15 files changed, +4127 insertions, -8 deletions
@@ -13,6 +13,18 @@ dependencies = [
13 13 ]
14 14
15 15 [[package]]
16 + name = "ahash"
17 + version = "0.8.12"
18 + source = "registry+https://github.com/rust-lang/crates.io-index"
19 + checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
20 + dependencies = [
21 + "cfg-if",
22 + "once_cell",
23 + "version_check",
24 + "zerocopy",
25 + ]
26 +
27 + [[package]]
16 28 name = "aho-corasick"
17 29 version = "1.1.4"
18 30 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -301,6 +313,18 @@ dependencies = [
301 313 ]
302 314
303 315 [[package]]
316 + name = "fallible-iterator"
317 + version = "0.3.0"
318 + source = "registry+https://github.com/rust-lang/crates.io-index"
319 + checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
320 +
321 + [[package]]
322 + name = "fallible-streaming-iterator"
323 + version = "0.1.9"
324 + source = "registry+https://github.com/rust-lang/crates.io-index"
325 + checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
326 +
327 + [[package]]
304 328 name = "fastrand"
305 329 version = "2.3.0"
306 330 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -503,6 +527,15 @@ dependencies = [
503 527
504 528 [[package]]
505 529 name = "hashbrown"
530 + version = "0.14.5"
531 + source = "registry+https://github.com/rust-lang/crates.io-index"
532 + checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
533 + dependencies = [
534 + "ahash",
535 + ]
536 +
537 + [[package]]
538 + name = "hashbrown"
506 539 version = "0.15.5"
507 540 source = "registry+https://github.com/rust-lang/crates.io-index"
508 541 checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
@@ -517,6 +550,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
517 550 checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
518 551
519 552 [[package]]
553 + name = "hashlink"
554 + version = "0.9.1"
555 + source = "registry+https://github.com/rust-lang/crates.io-index"
556 + checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
557 + dependencies = [
558 + "hashbrown 0.14.5",
559 + ]
560 +
561 + [[package]]
520 562 name = "heck"
521 563 version = "0.5.0"
522 564 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -872,6 +914,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
872 914 checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
873 915
874 916 [[package]]
917 + name = "libsqlite3-sys"
918 + version = "0.30.1"
919 + source = "registry+https://github.com/rust-lang/crates.io-index"
920 + checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
921 + dependencies = [
922 + "cc",
923 + "pkg-config",
924 + "vcpkg",
925 + ]
926 +
927 + [[package]]
875 928 name = "linux-keyutils"
876 929 version = "0.2.5"
877 930 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1278,6 +1331,20 @@ dependencies = [
1278 1331 ]
1279 1332
1280 1333 [[package]]
1334 + name = "rusqlite"
1335 + version = "0.32.1"
1336 + source = "registry+https://github.com/rust-lang/crates.io-index"
1337 + checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
1338 + dependencies = [
1339 + "bitflags",
1340 + "fallible-iterator",
1341 + "fallible-streaming-iterator",
1342 + "hashlink",
1343 + "libsqlite3-sys",
1344 + "smallvec",
1345 + ]
1346 +
1347 + [[package]]
1281 1348 name = "rustix"
1282 1349 version = "1.1.4"
1283 1350 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1531,6 +1598,7 @@ dependencies = [
1531 1598 "parking_lot",
1532 1599 "rand",
1533 1600 "reqwest",
1601 + "rusqlite",
1534 1602 "serde",
1535 1603 "serde_json",
1536 1604 "sha2",
@@ -6,8 +6,13 @@ description = "SyncKit client SDK with end-to-end encryption"
6 6 license-file = "LICENSE"
7 7
8 8 [features]
9 - default = ["keychain"]
9 + default = ["keychain", "store"]
10 10 keychain = ["dep:keyring"]
11 + # The higher-level SyncStore engine (owns a rusqlite connection to the app's DB).
12 + # Default-on so app consumers get it for free; a consumer that only needs the
13 + # transport/crypto SDK (e.g. mnw-cli, which sets `default-features = false`) skips
14 + # it and avoids compiling bundled SQLite.
15 + store = ["dep:rusqlite"]
11 16 # Exposes test-only constructors (`set_master_key_raw`, `with_http_client`) that
12 17 # bypass key derivation. Enabled automatically for this crate's own test builds
13 18 # via the self dev-dependency below; never part of `default`, so a real consumer
@@ -53,6 +58,10 @@ parking_lot = "0.12"
53 58 thiserror = "2"
54 59 tracing = "0.1"
55 60
61 + # SyncStore engine (feature = "store"). Owns the app's SQLite connection. Bundled
62 + # so no system libsqlite is needed; `functions` to register the `hash_row_id` UDF.
63 + rusqlite = { version = "0.32", features = ["bundled", "functions"], optional = true }
64 +
56 65 [dev-dependencies]
57 66 wiremock = "0.6"
58 67 sha2 = "0.10"
@@ -1,4 +1,4 @@
1 - # SyncKit Client SDK -- Architecture
1 + # SyncKit Client SDK, Architecture
2 2
3 3 ## Overview
4 4
@@ -125,7 +125,7 @@ from the OS keychain without any server interaction or password prompt.
125 125
126 126 ### The HLC envelope
127 127
128 - Every change — **including Deletes** — is encrypted as a JSON *envelope*, not as
128 + Every change, **including Deletes**, is encrypted as a JSON *envelope*, not as
129 129 the bare row data:
130 130
131 131 ```json
@@ -154,8 +154,8 @@ to a different row or table: the AAD no longer matches and the open fails closed
154 154 Legacy and v2 ciphertext coexist with no flag-day; key rotation re-encrypts
155 155 legacy entries into the v2 form opportunistically.
156 156
157 - The AAD is never passed as raw bytes. Callers name a context -- `AeadContext::Entry
158 - { table, row_id }` or `AeadContext::Blob { hash }` -- and the crypto layer derives
157 + The AAD is never passed as raw bytes. Callers name a context, `AeadContext::Entry
158 + { table, row_id }` or `AeadContext::Blob { hash }`, and the crypto layer derives
159 159 the AAD from it. The byte-level builder is private, so an empty or mismatched AAD
160 160 is not expressible at the call site.
161 161
@@ -202,7 +202,7 @@ whole-buffer with no flag-day.
202 202 **Caller contract.** The SDK does not persist the cursor or dedup re-delivered
203 203 changes. Persist the returned cursor only *after* applying the returned changes
204 204 (same transaction where possible), and make apply idempotent per
205 - `(table, row_id, hlc)` — a reconnect can re-deliver a batch.
205 + `(table, row_id, hlc)`, a reconnect can re-deliver a batch.
206 206
207 207 ### Conflict resolution
208 208
@@ -219,8 +219,8 @@ is returned as an opaque `CleanChanges`, not a bare `Vec`: the only way to read
219 219 the applicable entries out is `gated(committed_hlc)`, which drops any clean change
220 220 older-or-equal to the row's committed HLC. (`row_keys()` lets the caller pre-fetch
221 221 those clocks first.) This makes "apply a clean change without checking the
222 - committed clock" — which would let an older remote edit clobber a newer local
223 - value — unrepresentable rather than a contract the caller has to remember.
222 + committed clock", which would let an older remote edit clobber a newer local
223 + value, unrepresentable rather than a contract the caller has to remember.
224 224
225 225 ### Sequence numbers
226 226
@@ -328,3 +328,587 @@ storing long-lived credentials in memory.
328 328 - **Envelope versioning**: The key envelope includes a version field (`v: 1`)
329 329 and travels with its Argon2 cost parameters, so the work factor can be raised
330 330 later without breaking existing envelopes.
331 +
332 + ---
333 +
334 + # The `SyncStore` higher-level helper
335 +
336 + > Status: shipped in 0.6 behind the default-on `store` feature (`src/store/`).
337 + > Everything above describes the base SDK (transport + crypto + HLC + conflict
338 + > primitives); this section describes the layer *above* those primitives that
339 + > absorbs the SQLite plumbing the three first-party apps otherwise hand-write.
340 + > The design was reverse-engineered from the GoingsOn, audiofiles, and Balanced
341 + > Breakfast sync services; a consumer that only needs the transport/crypto SDK
342 + > (e.g. mnw-cli) sets `default-features = false` and skips it.
343 +
344 + ## Motivation
345 +
346 + Without this layer the SDK stops at "encrypt/decrypt a `Vec<ChangeEntry>` and
347 + move it over the wire," and each consuming app writes ~700–1,000 lines of
348 + *identical* engine on top of it: a `sync_changelog` + `sync_state` schema, per-table triggers, a
349 + drain-loop push, a cursor-loop pull, an FK-ordered apply with JSON→SQL binding,
350 + HLC stamping, an initial snapshot, retention/cleanup, and a scheduler
351 + (tick + SSE + backoff + status events). Measured across the three apps this is
352 + the overwhelming majority of their sync code, and it is mechanical.
353 +
354 + The single largest boilerplate source is **column-list triplication**: every
355 + syncable table names its synced columns three or four times, in the trigger DDL
356 + (`json_object(...)`), in the initial-snapshot projection, and in the apply-side
357 + whitelist, kept in agreement only by hand and a round-trip drift test. When they
358 + drift, a column silently arrives NULL (this is a real shipped bug class). One
359 + declared column list should generate all of them.
360 +
361 + The goal of `SyncStore` is: an app **declares its tables and policy once**, and
362 + the engine owns everything mechanical. This is the "simple" pillar of the product
363 + (see the private roadmap), the differentiators (private, secure, economical) are
364 + already in the 0.6 primitives; this layer is what makes them easy to adopt.
365 +
366 + ## What is generic (absorbed) vs. what is policy (declared)
367 +
368 + The three apps agree, byte-for-byte in spirit, on all of this, it moves into the
369 + engine:
370 +
371 + - `sync_state` KV + the transactional `applying_remote` echo-suppression flag.
372 + - `sync_changelog` schema, the `pushed` flag, cursor bookkeeping.
373 + - Push: HLC-stamp unpushed rows, batch ≤500, map to `ChangeEntry`, `client.push`,
374 + mark pushed, skip-and-retain corrupt/unknown-op rows, drain until empty.
375 + - Pull: paginated `client.pull_rich` loop, apply-then-persist-cursor (the SDK's
376 + documented crash-safe contract), auto-decrypt.
377 + - Apply: partition into upserts/deletes, iterate the declared table order
378 + (parents-first upserts, children-first deletes), JSON→SQL type coercion,
379 + per-row constraint-violation *skip-and-log* (a poison row never wedges the
380 + cursor), HLC gating against committed clocks, `resolve_lww` for true conflicts.
381 + - Initial snapshot, `cleanup_changelog` (prune pushed > N days), changelog
382 + retention cap, pending-change count.
383 + - The scheduler skeleton: 60 s tick raced against the SSE `changed` stream, the
384 + auth/key/enabled gate chain, a shared sync mutex, exponential backoff, and the
385 + 402 → subscription-required special case.
386 + - Device registration and the `perform_sync` orchestration order.
387 +
388 + Everything an app genuinely varies is small and enumerable. It becomes a
389 + declarative `SyncSchema` plus a couple of trait hooks:
390 +
391 + | Policy | Seen in | Expressed as |
392 + |---|---|---|
393 + | Which tables, their columns, FK order | all | `SyncSchema { tables: [SyncTable…] }`, declaration order = FK order |
394 + | Primary key shape (single / composite) | BB tag tables, AF `tags` | `PrimaryKey::{Single, Composite}` |
395 + | Partial-column, UPDATE-only sync | BB `feed_items` (is_read/is_starred) | `SyncMode::PartialUpdate { set }` |
396 + | Ignore remote deletes | BB `feed_items` | `DeleteMode::Ignore` |
397 + | Soft-delete (tombstone) instead of hard | AF `samples.deleted_at` (CASCADE safety) | `DeleteMode::Tombstone { column }` |
398 + | Preserve local-only columns on upsert | GO `email_accounts` credentials | `preserve_local: &[col]` |
399 + | Column defaults on first insert | GO `password=''` for NOT NULL | `insert_defaults: &[(col, val)]` |
400 + | Row references an *unsynced* table (relax FK) | GO `tasks.source_email_id` | `references_unsynced: true` |
401 + | Row-id privacy hashing on the wire | AF content tables (`hash_row_id(salt,…)`) | `RowIdScheme::Hashed` |
402 + | Exclude some rows/keys from sync | AF `user_config` `sync_*`, `loose_files` | `exclude_where: Option<&str>` (a SQL predicate, see AF example; must be SQL because it also compiles into the generated trigger's WHEN clause, not just the apply guard) |
403 + | Conflict model | GO/AF (HLC), BB (server-order) | `ConflictStrategy::{HybridLogicalClock, ServerOrder}` |
404 + | Blob-bearing tables | AF only | `BlobPolicy` trait (below) |
405 + | Retention cap, cleanup window, batch, interval | all (different numbers) | `SyncConfig` |
406 + | Device-name source, status sink, error mapping | all (framework-specific) | `SyncObserver` trait |
407 +
408 + User scoping (a multi-tenant `WHERE user_id = ?` filter) is deliberately **not**
409 + an engine-level concern. The GoingsOn worked example (below) confirmed the single-
410 + user desktop apps never scope the *changelog* by user, the only place a user
411 + filter appears is inside GO's blob queries, which is policy-local to its
412 + `BlobPolicy`. A changelog-level user filter belongs to the future multi-tenant
413 + work (a third-party-phase prerequisite), not this layer.
414 +
415 + ## The declarative schema
416 +
417 + ```rust
418 + // Abridged from the shipped `store::schema` types. The real fields are private
419 + // (`pub(crate)`) and constructed through the `SyncSchema`/`SyncTable` builders
420 + // (`.table(...)`, `.pk(&[...])`, `.conflict(...)`); shown here public for shape.
421 + pub struct SyncSchema {
422 + /// Declaration order IS the FK order: parents first. The engine upserts in
423 + /// this order and deletes in the reverse. No hand-maintained reversed list.
424 + pub tables: Vec<SyncTable>,
425 + /// Schema-wide conflict model (HLC or server-order).
426 + pub conflict: ConflictStrategy,
427 + }
428 +
429 + pub struct SyncTable {
430 + pub name: &'static str,
431 + /// The one column whitelist. Drives generated triggers, the snapshot
432 + /// projection, and apply-side binding — killing the triplication.
433 + pub columns: &'static [&'static str],
434 + /// PK column(s): one element = single key, more = composite (concatenated
435 + /// with ':' for the wire row-id). A slice, not a Single/Composite enum —
436 + /// simpler and matches the `.pk(&[...])` builder. Defaults to `["id"]`.
437 + pub pk: &'static [&'static str],
438 + pub mode: SyncMode,
439 + pub deletes: DeleteMode,
440 + pub row_id: RowIdScheme,
441 + /// Columns to leave untouched on upsert (secrets that stay per-device).
442 + pub preserve_local: &'static [&'static str],
443 + /// Values to inject only on first INSERT (satisfy NOT NULL on preserved cols).
444 + pub insert_defaults: &'static [(&'static str, &'static str)],
445 + /// This table carries FKs into a table that is not synced; relax FK
446 + /// enforcement while applying it.
447 + pub references_unsynced: bool,
448 + /// Optional SQL predicate that excludes rows from sync (AF config-key
449 + /// denylist). A SQL string, not a closure, because it also compiles into
450 + /// the generated trigger's WHEN clause — not just the apply-side guard.
451 + pub exclude_where: Option<&'static str>,
452 + }
453 +
454 + pub enum SyncMode {
455 + /// Upsert every whitelisted column (INSERT … ON CONFLICT DO UPDATE).
456 + Full,
457 + /// UPDATE only `set` columns where PK matches; never insert, never replace.
458 + /// BB feed_items: sync just is_read/is_starred, never the article body.
459 + PartialUpdate { set: &'static [&'static str] },
460 + }
461 +
462 + pub enum DeleteMode {
463 + Hard,
464 + /// Remote deletes are dropped on the floor (BB feed_items — content
465 + /// re-fetches from source).
466 + Ignore,
467 + /// Apply as `UPDATE … SET <column> = now()` instead of DELETE, so local
468 + /// ON DELETE CASCADE can't wipe organization (AF samples).
469 + Tombstone { column: &'static str },
470 + }
471 +
472 + pub enum RowIdScheme {
473 + /// Wire row_id is the cleartext primary key (GO, BB).
474 + PrimaryKey,
475 + /// Wire row_id is a salted hash of the PK; the cleartext PK is carried
476 + /// inside the (encrypted) payload for DELETEs (AF content tables). Requires
477 + /// three engine duties: own a per-vault `row_id_salt` in sync_state (generate
478 + /// once, never sync), register the `hash_row_id` SQL function on the
479 + /// connection, and make the generated DELETE trigger emit the PK column(s)
480 + /// into `data` (so pull reconstructs the WHERE from the payload, never the
481 + /// opaque row_id). Assigned per-table: content-bearing keys are Hashed,
482 + /// opaque-integer / closed-config keys stay PrimaryKey.
483 + Hashed,
484 + }
485 + ```
486 +
487 + Note the FK-order simplification: today every app keeps `UPSERT_ORDER` *and* a
488 + hand-written `DELETE_ORDER` that a test asserts is its exact reverse. Declaration
489 + order supplies both.
490 +
491 + ## Generated migrations (the triplication killer)
492 +
493 + ```rust
494 + impl SyncSchema {
495 + /// Emit the full sync DDL: sync_state, sync_changelog, the HLC ledger tables,
496 + /// and every table's INSERT/UPDATE/DELETE trigger — the applying_remote guard,
497 + /// the json_object projection over `columns`, the extra OLD≠NEW guard for
498 + /// PartialUpdate tables, and the row_id expression (cleartext or hashed) — all
499 + /// derived from the same declaration the apply side reads.
500 + pub fn migration_sql(&self) -> String;
501 + }
502 + ```
503 +
504 + An app runs `migration_sql()` once as a normal migration. Because triggers,
505 + snapshot, and apply all read one `columns` list, the drift class is eliminated by
506 + construction and the round-trip test each app carries today is deleted.
507 +
508 + ## The engine and its database seam
509 +
510 + The one real portability constraint: GO and BB drive SQLite through an async
511 + `sqlx::SqlitePool`; audiofiles uses `rusqlite` behind `spawn_blocking` (its
512 + `Connection` is `!Send`). The engine must not marry either driver.
513 +
514 + Resolution: **`SyncStore` owns its own `rusqlite` connection(s) to the app's
515 + SQLite file** (given a path or a connection factory). Under WAL, which all three
516 + apps already use, the engine's short read/apply transactions coexist with the
517 + app's own pool on the same file. The app's domain code is untouched; the sync
518 + engine simply becomes another connection that reads `sync_changelog` and writes
519 + the domain tables under the `applying_remote` guard. This also matches audiofiles
520 + exactly and removes GO/BB's async-sync code entirely.
521 +
522 + Per-connection state the engine controls on its own connection: `PRAGMA
523 + foreign_keys` (toggled off only while applying a `references_unsynced` table, then
524 + restored, or the connection dropped rather than returned in an unknown state),
525 + and the transaction that raises/lowers `applying_remote` *inside* the same commit
526 + as the writes (so a crash rolls the flag back, never stranding it at `'1'` and
527 + silently swallowing later local edits).
528 +
529 + ```rust
530 + pub struct SyncStore { /* schema, client, config, db factory, blob policy, observer */ }
531 +
532 + impl SyncStore {
533 + pub fn builder(db: DbSource, client: SyncKitClient, schema: SyncSchema) -> SyncStoreBuilder;
534 +
535 + /// One full cycle: ensure device → push → pull → (blobs) → stamp last_sync.
536 + pub async fn sync_now(&self) -> Result<SyncOutcome>;
537 +
538 + /// Own the whole background loop (tick + SSE + gates + backoff), reporting
539 + /// through the SyncObserver. Returns a handle to stop it.
540 + pub fn spawn_scheduler(&self, observer: Arc<dyn SyncObserver>) -> SchedulerHandle;
541 +
542 + pub async fn pending_changes(&self) -> Result<u64>;
543 + pub async fn disconnect(&self) -> Result<()>;
544 + }
545 +
546 + pub struct SyncOutcome { pub pushed: u64, pub pulled: u64, pub changed_tables: HashSet<String> }
547 + ```
548 +
549 + ## Conflict strategy
550 +
551 + GO and AF run the full HLC pipeline (`detect_conflicts` → committed-HLC gating →
552 + `resolve_lww`); BB currently sends a nil HLC and leans on server ordering. Rather
553 + than force a migration, expose it:
554 +
555 + ```rust
556 + pub enum ConflictStrategy {
557 + /// Stamp a real HLC per edit, gate clean changes against committed clocks,
558 + /// resolve true conflicts with resolve_lww. Deterministic multi-device
559 + /// convergence. The default and recommended setting.
560 + HybridLogicalClock,
561 + /// Trust server seq order; last delivered wins. Simplest; adequate when
562 + /// concurrent edits to the same row are rare/uninteresting.
563 + ServerOrder,
564 + }
565 + ```
566 +
567 + Default `HybridLogicalClock`; BB can adopt it (it is the roadmap intent anyway) or
568 + stay on `ServerOrder` during migration.
569 +
570 + ## Blob policy
571 +
572 + Only audiofiles syncs blobs, but the shape is general: some rows own a
573 + content-addressed binary, gated by an enable flag, with a local/cloud presence
574 + bit and an in-place escape hatch. Expressed as a trait the engine drives *after*
575 + the metadata pull, non-fatally:
576 +
577 + ```rust
578 + pub trait BlobPolicy: Send + Sync {
579 + /// Rows whose bytes should be uploaded (AF: samples in a sync_files VFS,
580 + /// not cloud_only, with a local file present). The engine skips any whose
581 + /// local_path() is absent, so this may return the full candidate set.
582 + fn pending_uploads(&self, conn: &Connection) -> Result<Vec<BlobRef>>;
583 + /// Rows whose bytes are missing locally and wanted (AF: sync_files VFS,
584 + /// source_path IS NULL). The engine skips any whose local_path() exists.
585 + fn pending_downloads(&self, conn: &Connection) -> Result<Vec<BlobRef>>;
586 + fn local_path(&self, blob: &BlobRef) -> PathBuf;
587 +
588 + // Presence bookkeeping — default no-ops. An app that tracks local-vs-cloud
589 + // presence purely by disk existence (GoingsOn) implements none of these;
590 + // an app with an explicit presence flag (AF cloud_only) overrides them.
591 + /// Reconcile presence flags before the passes (AF: mark_cloud_only_samples).
592 + fn reconcile(&self, _conn: &Connection) -> Result<()> { Ok(()) }
593 + /// Flip bookkeeping after upload, echo-suppressed (AF: cloud_only).
594 + fn on_uploaded(&self, _conn: &Connection, _blob: &BlobRef) -> Result<()> { Ok(()) }
595 + /// Flip bookkeeping after download, echo-suppressed (AF: cloud_only).
596 + fn on_downloaded(&self, _conn: &Connection, _blob: &BlobRef) -> Result<()> { Ok(()) }
597 + }
598 +
599 + pub struct BlobRef { pub hash: String, pub ext: String, pub size: u64 }
600 + ```
601 +
602 + The engine owns the invariant parts: the `blob_upload_url` → dedup-skip-if-exists
603 + → skip-if-`local_path`-absent → `blob_upload` → `blob_confirm` dance, the
604 + `blob_download_url` → skip-if-`local_path`-present → `blob_download` → **SHA-256
605 + re-verify** → atomic temp+rename write, and the single-item `download_one(hash)`
606 + path for a GUI "download now" action. The policy trait only answers "which blobs,"
607 + "where on disk," and (optionally) "how to reflect presence." GoingsOn's attachment
608 + policy needs only the first three methods; audiofiles overrides all six.
609 +
610 + ## Status reporting
611 +
612 + Status must be framework-agnostic: GO/BB emit Tauri events, AF mutates an
613 + `Arc<Mutex<SyncStatus>>`. A trait bridges both:
614 +
615 + ```rust
616 + pub trait SyncObserver: Send + Sync {
617 + fn on_status(&self, status: SyncStatus); // Syncing/Idle/Error/SubscriptionRequired/LoggedOut
618 + fn on_changes_applied(&self, tables: &HashSet<String>); // selective cache invalidation
619 + fn on_subscription_required(&self);
620 + }
621 + ```
622 +
623 + ## What each app sheds
624 +
625 + | App | Today | After `SyncStore` |
626 + |---|---|---|
627 + | **GoingsOn** | ~1,400 non-test LOC across push/pull/apply/hlc/state/blob_sync/scheduler + trigger DDL + drift test | A 20-table `SyncSchema`, `email_accounts` `preserve_local`+`insert_defaults`, `references_unsynced` on `tasks`+`attachments`, a thin attachment `BlobPolicy`, a Tauri `SyncObserver` (see worked example below) |
628 + | **audiofiles** | ~1,400 non-test LOC + the whole blob subsystem + tombstone/hash-id/config-exclusion logic | A 16-table `SyncSchema` (per-table `Hashed` row-ids, `Tombstone` on samples, `exclude_where` on user_config, composite/all-PK tables), the full 5-method `BlobPolicy` impl (kept, genuine policy), an `Arc<Mutex>` `SyncObserver` (see worked example below) |
629 + | **Balanced Breakfast** | ~700 non-test LOC + triplicated columns | A 7-table `SyncSchema` with `feed_items = PartialUpdate{set:[is_read,is_starred]} + DeleteMode::Ignore`, `MAX_CHANGELOG_ENTRIES` in `SyncConfig`, a Tauri `SyncObserver`. Adopts `HybridLogicalClock` |
630 +
631 + ## Design decisions
632 +
633 + Decided (2026-07-12):
634 +
635 + 1. **DB ownership: the engine owns a private `rusqlite` connection to the app's
636 + WAL file.** Not a `SyncDb` trait over the app's `sqlx` pool. This is far
637 + simpler, matches audiofiles exactly, and lets GoingsOn and Balanced Breakfast
638 + delete their async sync code wholesale. The engine's short read/apply
639 + transactions coexist with the app's own pool under WAL. Consequence: the app
640 + passes a path (or a connection factory for custom pragmas/shared-cache), and
641 + the engine, not the app, holds the FK-pragma and `applying_remote` state on its
642 + own connection.
643 + 2. **Triggers are generated from the manifest**, not hand-written SQL. One
644 + `columns` list per table produces the trigger DDL, the snapshot projection, and
645 + the apply binding, so the column-drift bug class is closed by construction and
646 + the per-app round-trip drift test is deleted. Accepted cost: the engine emits
647 + SQL the app reviews once at migration time.
648 +
649 + Remaining to confirm during build (not blocking):
650 +
651 + 3. **Null-tolerance merge policy** (omit NOT-NULL-null columns, keep nullable-null)
652 + reads the live schema, so it defaults into the engine; confirm no app needs a
653 + different per-table rule.
654 + 4. **`extra`/forward-compat fields**, the apply path must thread
655 + `ChangeEntry.extra` through unchanged so a newer client's fields survive an
656 + older client's round-trip; confirm nothing drops them.
657 +
658 + ## Worked example: GoingsOn
659 +
660 + GoingsOn is the richest consumer (20 tables, credential exclusion, cross-table
661 + FKs into unsynced data, blobs, HLC), so it is the design's stress test. Its entire
662 + sync engine, `push.rs`, `pull.rs`, `apply.rs`, `hlc.rs`, `state.rs`, `blob_sync.rs`,
663 + most of `mod.rs`, the hand-written trigger migrations, and the drift test, reduces
664 + to the manifest and one thin trait impl below. Column lists are the *actual*
665 + `SYNCED_COLUMNS` from `apply.rs`.
666 +
667 + ```rust
668 + use synckit_client::store::{SyncSchema, SyncTable, ConflictStrategy};
669 +
670 + // Common case: single `id` PK, cleartext wire row-id, Full upsert (ON CONFLICT
671 + // DO UPDATE — never INSERT OR REPLACE, so ON DELETE CASCADE can't wipe children),
672 + // Hard delete, HLC conflict resolution. 17 of GO's 20 tables are one line each.
673 + fn t(name: &'static str, cols: &'static [&'static str]) -> SyncTable {
674 + SyncTable::full(name, cols)
675 + }
676 +
677 + pub fn goingson_schema() -> SyncSchema {
678 + // Declaration order = FK order (parents first). Deletes run the exact reverse
679 + // automatically — no hand-maintained DELETE_ORDER, no test asserting it.
680 + SyncSchema::new(vec![
681 + t("projects", &["id","name","description","project_type","status","created_at","user_id"]),
682 + t("contacts", &["id","user_id","display_name","nickname","company","title","notes","tags",
683 + "birthday","timezone","external_source","external_id","created_at","updated_at"]),
684 +
685 + // email_accounts: sync config, NEVER credentials. GO's bespoke ~40-line
686 + // apply_email_account_upsert() dissolves into these two declarative fields:
687 + // preserve_local → the 4 secret columns are untouched on conflict-update
688 + // insert_defaults → new rows get password='' to satisfy its NOT NULL
689 + SyncTable::full("email_accounts",
690 + &["id","user_id","account_name","email_address","imap_server","imap_port",
691 + "smtp_server","smtp_port","username","use_tls","created_at","archive_folder_name",
692 + "auth_type","jmap_session_url","jmap_account_id","sync_interval_minutes","email_signature"])
693 + .preserve_local(&["password","oauth2_access_token","oauth2_refresh_token","oauth2_token_expires_at"])
694 + .insert_defaults(&[("password", "")]),
695 +
696 + t("sync_accounts", &["id","user_id","provider","account_name","email","sync_calendars",
697 + "sync_contacts","calendar_ids","sync_interval_minutes","enabled","created_at"]),
698 + t("milestones", &["id","user_id","project_id","name","description","position","target_date",
699 + "status","created_at"]),
700 +
701 + // tasks & attachments both carry source_email_id — a FK into the emails
702 + // table, which is NOT synced (emails stay per-device). Relax FK enforcement
703 + // while applying them. (In GO today this is a blanket PRAGMA foreign_keys=OFF
704 + // over the whole apply; the engine does the same when any batched table is
705 + // references_unsynced.)
706 + SyncTable::full("tasks",
707 + &["id","project_id","description","status","priority","due","tags","urgency","recurrence",
708 + "recurrence_rule","created_at","user_id","recurrence_parent_id","source_email_id",
709 + "snoozed_until","waiting_for_response","waiting_since","expected_response_date",
710 + "scheduled_start","scheduled_duration","is_focus","focus_set_at","contact_id",
711 + "milestone_id","completed_at","estimated_minutes","actual_minutes"])
712 + .references_unsynced(),
713 + t("time_sessions", &["id","task_id","user_id","started_at","ended_at","duration_minutes","created_at"]),
714 + SyncTable::full("attachments",
715 + &["id","user_id","task_id","project_id","filename","file_size","mime_type","blob_hash",
716 + "source_email_id","created_at"])
717 + .references_unsynced(),
718 +
719 + t("events", &["id","project_id","title","description","start_time","end_time","location","user_id",
720 + "linked_task_id","recurrence","recurrence_parent_id","recurrence_rule","contact_id",
721 + "block_type","external_source","external_id","is_read_only","snoozed_until",
722 + "reminder_offsets_seconds"]),
723 + t("annotations", &["id","task_id","timestamp","note"]),
724 + t("subtasks", &["id","task_id","text","is_completed","position","created_at","linked_task_id"]),
725 + t("contact_emails", &["id","contact_id","address","label","is_primary"]),
726 + t("contact_phones", &["id","contact_id","number","label","is_primary"]),
727 + t("contact_social_handles", &["id","contact_id","platform","handle","url"]),
728 + t("contact_custom_fields", &["id","contact_id","label","value","url"]),
729 + t("daily_notes", &["id","user_id","note_date","went_well","could_improve","is_reviewed",
730 + "reviewed_at","created_at","updated_at"]),
731 + t("saved_views", &["id","user_id","name","view_type","filters","sort_by","sort_order",
732 + "is_pinned","position","created_at","updated_at"]),
733 + t("weekly_reviews", &["id","user_id","week_start_date","completed_at","notes","vacation_days"]),
734 + t("monthly_goals", &["id","user_id","month","text","status","position","created_at","updated_at"]),
735 + t("monthly_reflections", &["id","user_id","month","highlight_text","change_text","completed_at"]),
736 + ])
737 + .conflict_strategy(ConflictStrategy::HybridLogicalClock) // GO's current model; also the default
738 + }
739 + ```
740 +
741 + The blob policy is thin, GO tracks local-vs-cloud presence purely by disk
742 + existence (no `cloud_only` flag), so it implements only the three required methods
743 + and inherits the no-op presence hooks:
744 +
745 + ```rust
746 + struct AttachmentBlobs { user_id: String, data_dir: PathBuf } // user_id = DESKTOP_USER_ID
747 +
748 + impl BlobPolicy for AttachmentBlobs {
749 + fn pending_uploads(&self, conn: &Connection) -> Result<Vec<BlobRef>> {
750 + // SELECT DISTINCT blob_hash, file_size FROM attachments WHERE user_id = ?
751 + // (engine skips any whose local_path is absent)
752 + }
753 + fn pending_downloads(&self, conn: &Connection) -> Result<Vec<BlobRef>> {
754 + // SELECT DISTINCT blob_hash FROM attachments WHERE user_id = ?
755 + // (engine skips any whose local_path already exists; then SHA-256-verifies)
756 + }
757 + fn local_path(&self, blob: &BlobRef) -> PathBuf { blob_path(&self.data_dir, &blob.hash) }
758 + // reconcile / on_uploaded / on_downloaded: inherited no-ops.
759 + }
760 + ```
761 +
762 + ### What GO proved about the design
763 +
764 + - **Confirmed: credential exclusion collapses.** The single most bespoke piece of
765 + GO's apply layer (`apply_email_account_upsert`, its own INSERT-with-`password=''`
766 + and 16-column ON CONFLICT) is fully expressed by `preserve_local` +
767 + `insert_defaults`. No per-table override function survives. This was the strongest
768 + signal the enum set is at the right altitude.
769 + - **Confirmed: 17 of 20 tables are one line.** Only `email_accounts`, `tasks`, and
770 + `attachments` need a builder call; the rest are `t(name, cols)`.
771 + - **Refinement 1: `user_scope` is not an engine concept.** GO never filters the
772 + *changelog* by user; the only `WHERE user_id = ?` is inside its blob queries.
773 + So user scoping is policy-local (it lives in `AttachmentBlobs`), and the engine-
774 + level `user_scope` field was removed. A changelog-level tenant filter is deferred
775 + to the future multi-tenant work.
776 + - **Refinement 2: blob presence hooks must default to no-ops.** GO has no
777 + `cloud_only` equivalent, so `reconcile`/`on_uploaded`/`on_downloaded` gained
778 + default empty bodies; only AF overrides them.
779 + - **Corrected: `references_unsynced` covers `attachments` too**, not just `tasks`
780 + (both carry `source_email_id`). The engine's rule "disable FK for the apply txn if
781 + any batched table is `references_unsynced`" matches GO's existing blanket toggle.
782 +
783 + ## Worked example: audiofiles
784 +
785 + audiofiles is the design's hardest test: it exercises everything GoingsOn does not
786 + , salted-hash wire row-ids, tombstone deletes, a SQL exclusion predicate, composite
Lines truncated
@@ -81,6 +81,11 @@ pub enum SyncKitError {
81 81 #[cfg(feature = "keychain")]
82 82 #[error("Keychain error: {0}")]
83 83 Keychain(String),
84 +
85 + /// A SQLite operation in the `SyncStore` engine failed.
86 + #[cfg(feature = "store")]
87 + #[error("Database error: {0}")]
88 + Database(String),
84 89 }
85 90
86 91 #[cfg(feature = "keychain")]
@@ -90,6 +95,13 @@ impl From<keyring::Error> for SyncKitError {
90 95 }
91 96 }
92 97
98 + #[cfg(feature = "store")]
99 + impl From<rusqlite::Error> for SyncKitError {
100 + fn from(e: rusqlite::Error) -> Self {
101 + SyncKitError::Database(e.to_string())
102 + }
103 + }
104 +
93 105 /// Convenience alias.
94 106 pub type Result<T> = std::result::Result<T, SyncKitError>;
95 107
@@ -67,6 +67,8 @@ pub mod error;
67 67 pub mod ids;
68 68 pub mod keystore;
69 69 pub mod oauth;
70 + #[cfg(feature = "store")]
71 + pub mod store;
70 72 pub mod types;
71 73
72 74 // Re-exports for convenience
@@ -83,4 +85,9 @@ pub use conflict::{
83 85 };
84 86 pub use error::{Result, SyncKitError};
85 87 pub use ids::{AppId, DeviceId, UserId};
88 + #[cfg(feature = "store")]
89 + pub use store::{
90 + BlobPolicy, BlobRef, ConflictStrategy, DbSource, DeleteMode, RowIdScheme, SyncConfig,
91 + SyncObserver, SyncOutcome, SyncSchema, SyncState, SyncStore, SyncTable, SyncMode,
92 + };
86 93 pub use types::{ChangeEntry, ChangeOp, Device, Hlc, PullFilter, PulledChange, SyncStatus};
@@ -0,0 +1,739 @@
1 + //! Applying resolved remote changes to the local database.
2 + //!
3 + //! [`apply_remote_changes`] takes changes the conflict layer has already resolved
4 + //! and writes them, in foreign-key-safe order, inside the crash-safe
5 + //! `applying_remote` transaction from [`super::db`]. All per-table policy declared
6 + //! in the [`SyncSchema`](super::schema::SyncSchema) resolves here: `Full` vs
7 + //! `PartialUpdate` upserts, `Hard`/`Ignore`/`Tombstone` deletes, `preserve_local`
8 + //! secrets, `insert_defaults`, NOT-NULL null-tolerance, the `exclude_where` import
9 + //! guard, hashed-row reconstruction from the payload, and `references_unsynced`
10 + //! FK relaxation.
11 + //!
12 + //! A single row failing a constraint is skipped and logged, never fatal, so a
13 + //! poison row cannot wedge cursor advancement; a genuine (non-constraint) SQLite
14 + //! error rolls the whole batch back.
15 +
16 + use std::collections::{HashMap, HashSet};
17 +
18 + use rusqlite::types::ToSql;
19 + use rusqlite::{Connection, ErrorCode, Transaction};
20 + use serde_json::Value;
21 + use tracing::warn;
22 +
23 + use super::db::with_applying_remote;
24 + use super::schema::{DeleteMode, SyncMode, SyncSchema, SyncTable};
25 + use crate::error::Result;
26 + use crate::types::{ChangeEntry, ChangeOp};
27 +
28 + /// A JSON null to borrow when a payload omits a whitelisted column.
29 + static JSON_NULL: Value = Value::Null;
30 +
31 + /// Summary of an apply pass.
32 + #[derive(Debug, Default, PartialEq, Eq)]
33 + pub struct ApplyOutcome {
34 + /// Rows written (insert/update/delete/tombstone).
35 + pub applied: usize,
36 + /// Rows intentionally dropped or unappliable (excluded, unknown table,
37 + /// missing key, or a skipped constraint violation).
38 + pub skipped: usize,
39 + /// Distinct tables an applied change touched, for selective UI invalidation.
40 + pub changed_tables: HashSet<String>,
41 + }
42 +
43 + /// Apply resolved remote `changes` to `conn` in FK-safe order.
44 + ///
45 + /// Upserts run parents-first (schema declaration order); deletes run
46 + /// children-first (reverse). If any change targets a `references_unsynced` table,
47 + /// foreign-key enforcement is disabled for the whole apply — SQLite only honours
48 + /// a `foreign_keys` pragma change *outside* a transaction, so it is toggled around
49 + /// the `applying_remote` transaction, not inside it.
50 + pub fn apply_remote_changes(
51 + conn: &mut Connection,
52 + schema: &SyncSchema,
53 + changes: &[ChangeEntry],
54 + ) -> Result<ApplyOutcome> {
55 + let by_name: HashMap<&str, &SyncTable> =
56 + schema.tables.iter().map(|t| (t.name, t)).collect();
57 +
58 + let fk_off = changes.iter().any(|c| {
59 + by_name
60 + .get(c.table.as_str())
61 + .is_some_and(|t| t.references_unsynced)
62 + });
63 + if fk_off {
64 + conn.execute_batch("PRAGMA foreign_keys=OFF")?;
65 + }
66 +
67 + let outcome = with_applying_remote(conn, |tx| apply_inner(tx, schema, &by_name, changes));
68 +
69 + if fk_off {
70 + // Restore enforcement regardless of the apply result. A failed restore is
71 + // self-healing: the engine re-asserts foreign_keys=ON when it next opens
72 + // the connection (see db::configure_connection).
73 + if let Err(e) = conn.execute_batch("PRAGMA foreign_keys=ON") {
74 + warn!("failed to restore foreign_keys=ON after apply: {e}");
75 + }
76 + }
77 +
78 + outcome
79 + }
80 +
81 + fn apply_inner(
82 + tx: &Transaction<'_>,
83 + schema: &SyncSchema,
84 + by_name: &HashMap<&str, &SyncTable>,
85 + changes: &[ChangeEntry],
86 + ) -> Result<ApplyOutcome> {
87 + let mut out = ApplyOutcome::default();
88 +
89 + // Changes targeting a table the schema doesn't know are dropped (defensive —
90 + // a hostile or newer server could send one), not applied.
91 + let unknown = changes
92 + .iter()
93 + .filter(|c| !by_name.contains_key(c.table.as_str()))
94 + .count();
95 + if unknown > 0 {
96 + warn!("dropping {unknown} change(s) for unknown table(s)");
97 + out.skipped += unknown;
98 + }
99 +
100 + // Precompute NOT NULL columns for each Full-mode table once, not per row.
101 + let mut not_null: HashMap<&str, HashSet<String>> = HashMap::new();
102 + for t in &schema.tables {
103 + if matches!(t.mode, SyncMode::Full) {
104 + not_null.insert(t.name, not_null_columns(tx, t.name)?);
105 + }
106 + }
107 +
108 + // Upserts: parents first.
109 + for table in &schema.tables {
110 + for change in changes.iter().filter(|c| {
111 + c.table == table.name && matches!(c.op, ChangeOp::Insert | ChangeOp::Update)
112 + }) {
113 + step(&mut out, table, apply_upsert(tx, table, change, &not_null))?;
114 + }
115 + }
116 +
117 + // Deletes: children first.
118 + for table in schema.tables.iter().rev() {
119 + for change in changes
120 + .iter()
121 + .filter(|c| c.table == table.name && matches!(c.op, ChangeOp::Delete))
122 + {
123 + step(&mut out, table, apply_delete(tx, table, change))?;
124 + }
125 + }
126 +
127 + Ok(out)
128 + }
129 +
130 + /// Fold one row's result into the outcome. A constraint violation is skipped and
131 + /// logged; any other SQLite error is returned so `?` rolls the whole batch back
132 + /// (converting to `SyncKitError` at the caller).
133 + fn step(
134 + out: &mut ApplyOutcome,
135 + table: &SyncTable,
136 + result: rusqlite::Result<bool>,
137 + ) -> rusqlite::Result<()> {
138 + match result {
139 + Ok(true) => {
140 + out.applied += 1;
141 + out.changed_tables.insert(table.name.to_string());
142 + Ok(())
143 + }
144 + Ok(false) => {
145 + out.skipped += 1;
146 + Ok(())
147 + }
148 + Err(e) if is_constraint_violation(&e) => {
149 + warn!(table = table.name, "skipping row on constraint violation: {e}");
150 + out.skipped += 1;
151 + Ok(())
152 + }
153 + Err(e) => Err(e),
154 + }
155 + }
156 +
157 + /// Names of the NOT NULL columns of `table`, read from the live schema so the
158 + /// list can never drift from the migrations.
159 + fn not_null_columns(tx: &Transaction<'_>, table: &str) -> rusqlite::Result<HashSet<String>> {
160 + let mut stmt = tx.prepare(&format!("PRAGMA table_info({table})"))?;
161 + let rows = stmt.query_map([], |r| {
162 + let name: String = r.get("name")?;
163 + let notnull: i64 = r.get("notnull")?;
164 + Ok((name, notnull != 0))
165 + })?;
166 + let mut set = HashSet::new();
167 + for row in rows {
168 + let (name, nn) = row?;
169 + if nn {
170 + set.insert(name);
171 + }
172 + }
173 + Ok(set)
174 + }
175 +
176 + fn field<'a>(data: &'a Value, col: &str) -> &'a Value {
177 + data.get(col).unwrap_or(&JSON_NULL)
178 + }
179 +
180 + /// Bind a JSON value as a SQLite parameter.
181 + fn json_to_sql(v: &Value) -> Box<dyn ToSql> {
182 + match v {
183 + Value::String(s) => Box::new(s.clone()),
184 + Value::Number(n) => {
185 + if let Some(i) = n.as_i64() {
186 + Box::new(i)
187 + } else if let Some(f) = n.as_f64() {
188 + Box::new(f)
189 + } else {
190 + Box::new(n.to_string())
191 + }
192 + }
193 + Value::Bool(b) => Box::new(i64::from(*b)),
194 + Value::Null => Box::new(rusqlite::types::Null),
195 + other => Box::new(other.to_string()),
196 + }
197 + }
198 +
199 + fn is_constraint_violation(e: &rusqlite::Error) -> bool {
200 + matches!(e, rusqlite::Error::SqliteFailure(err, _) if err.code == ErrorCode::ConstraintViolation)
201 + }
202 +
203 + // -- upsert --
204 +
205 + fn apply_upsert(
206 + tx: &Transaction<'_>,
207 + table: &SyncTable,
208 + change: &ChangeEntry,
209 + not_null: &HashMap<&str, HashSet<String>>,
210 + ) -> rusqlite::Result<bool> {
211 + let Some(data) = change.data.as_ref().filter(|v| v.is_object()) else {
212 + return Ok(false); // an upsert with no object payload cannot be applied
213 + };
214 + if is_excluded(tx, table, Some(data))? {
215 + return Ok(false);
216 + }
217 + match &table.mode {
218 + SyncMode::PartialUpdate { set } => apply_partial_update(tx, table, data, set),
219 + SyncMode::Full => {
220 + let nn = not_null.get(table.name).expect("Full table not_null precomputed");
221 + apply_full_upsert(tx, table, data, nn)
222 + }
223 + }
224 + }
225 +
226 + fn apply_full_upsert(
227 + tx: &Transaction<'_>,
228 + table: &SyncTable,
229 + data: &Value,
230 + not_null: &HashSet<String>,
231 + ) -> rusqlite::Result<bool> {
232 + // A remote null for a NOT NULL column is invalid input the changelog never
233 + // emits; omit such columns so a new row takes the schema default and an
234 + // existing row keeps its value. Nullable columns keep null so legitimate
235 + // clears still propagate.
236 + let present: Vec<&str> = table
237 + .columns
238 + .iter()
239 + .copied()
240 + .filter(|c| !(field(data, c).is_null() && not_null.contains(*c)))
241 + .collect();
242 +
243 + // INSERT columns = present whitelist columns + any insert_defaults not already
244 + // present (e.g. a NOT NULL preserved secret gets its default on first insert).
245 + let mut insert_cols: Vec<&str> = present.clone();
246 + let mut default_vals: Vec<&str> = Vec::new();
247 + for &(col, val) in table.insert_defaults {
248 + if !insert_cols.contains(&col) {
249 + insert_cols.push(col);
250 + default_vals.push(val);
251 + }
252 + }
253 + if insert_cols.is_empty() {
254 + return Ok(false);
255 + }
256 +
257 + let pk: HashSet<&str> = table.pk.iter().copied().collect();
258 + let preserve: HashSet<&str> = table.preserve_local.iter().copied().collect();
259 + // ON CONFLICT update touches present columns that are neither PK nor a
260 + // preserved local secret — so an existing row's secrets are never overwritten.
261 + let update_cols: Vec<&str> = present
262 + .iter()
263 + .copied()
264 + .filter(|c| !pk.contains(c) && !preserve.contains(c))
265 + .collect();
266 +
267 + let placeholders = (1..=insert_cols.len())
268 + .map(|i| format!("?{i}"))
269 + .collect::<Vec<_>>()
270 + .join(", ");
271 +
272 + let sql = if update_cols.is_empty() {
273 + // Every column is part of the PK (or nothing to update) — ignore conflicts
274 + // rather than DELETE+INSERT, which would cascade to children.
275 + format!(
276 + "INSERT OR IGNORE INTO {} ({}) VALUES ({})",
277 + table.name,
278 + insert_cols.join(", "),
279 + placeholders
280 + )
281 + } else {
282 + let set = update_cols
283 + .iter()
284 + .map(|c| format!("{c} = excluded.{c}"))
285 + .collect::<Vec<_>>()
286 + .join(", ");
287 + format!(
288 + "INSERT INTO {} ({}) VALUES ({}) ON CONFLICT({}) DO UPDATE SET {}",
289 + table.name,
290 + insert_cols.join(", "),
291 + placeholders,
292 + table.pk.join(", "),
293 + set
294 + )
295 + };
296 +
297 + let mut params: Vec<Box<dyn ToSql>> = present.iter().map(|c| json_to_sql(field(data, c))).collect();
298 + params.extend(default_vals.iter().map(|v| Box::new((*v).to_string()) as Box<dyn ToSql>));
299 +
300 + exec(tx, &sql, &params)?;
301 + Ok(true)
302 + }
303 +
304 + fn apply_partial_update(
305 + tx: &Transaction<'_>,
306 + table: &SyncTable,
307 + data: &Value,
308 + set: &[&str],
309 + ) -> rusqlite::Result<bool> {
310 + let Some(pk) = pk_bindings(table, data, None) else {
311 + return Ok(false); // no primary key to target
312 + };
313 + let set_present: Vec<&str> = set.iter().copied().filter(|c| data.get(*c).is_some()).collect();
314 + if set_present.is_empty() {
315 + return Ok(false);
316 + }
317 +
318 + let mut params: Vec<Box<dyn ToSql>> = Vec::new();
319 + let mut idx = 1;
320 + let set_sql = set_present
321 + .iter()
322 + .map(|c| {
323 + let s = format!("{c} = ?{idx}");
324 + idx += 1;
325 + params.push(json_to_sql(field(data, c)));
326 + s
327 + })
328 + .collect::<Vec<_>>()
329 + .join(", ");
330 + let where_sql = pk
331 + .into_iter()
332 + .map(|(c, v)| {
333 + let s = format!("{c} = ?{idx}");
334 + idx += 1;
335 + params.push(v);
336 + s
337 + })
338 + .collect::<Vec<_>>()
339 + .join(" AND ");
340 +
341 + let sql = format!("UPDATE {} SET {set_sql} WHERE {where_sql}", table.name);
342 + exec(tx, &sql, &params)?;
343 + Ok(true)
344 + }
345 +
346 + // -- delete --
347 +
348 + fn apply_delete(
349 + tx: &Transaction<'_>,
350 + table: &SyncTable,
351 + change: &ChangeEntry,
352 + ) -> rusqlite::Result<bool> {
353 + if matches!(table.deletes, DeleteMode::Ignore) {
354 + return Ok(false);
355 + }
356 + if is_excluded(tx, table, change.data.as_ref())? {
357 + return Ok(false);
358 + }
359 + let Some(pk) = pk_bindings(table, change.data.as_ref().unwrap_or(&JSON_NULL), Some(&change.row_id))
360 + else {
361 + return Ok(false); // can't reconstruct the key — skip
362 + };
363 +
364 + let mut params: Vec<Box<dyn ToSql>> = Vec::new();
365 + let mut idx = 1;
366 + let where_sql = pk
367 + .into_iter()
368 + .map(|(c, v)| {
369 + let s = format!("{c} = ?{idx}");
370 + idx += 1;
371 + params.push(v);
372 + s
373 + })
374 + .collect::<Vec<_>>()
375 + .join(" AND ");
376 +
377 + let sql = match &table.deletes {
378 + // A remote delete on a tombstone table must not hard-DELETE: a local
379 + // ON DELETE CASCADE would wipe organizational state the remote never
380 + // meant to touch. COALESCE keeps the earliest tombstone instant.
381 + DeleteMode::Tombstone { column } => format!(
382 + "UPDATE {} SET {column} = COALESCE({column}, unixepoch()) WHERE {where_sql}",
383 + table.name
384 + ),
385 + DeleteMode::Hard => format!("DELETE FROM {} WHERE {where_sql}", table.name),
386 + DeleteMode::Ignore => unreachable!("returned above"),
387 + };
388 + exec(tx, &sql, &params)?;
389 + Ok(true)
390 + }
391 +
392 + /// Reconstruct the primary-key bindings for a row.
393 + ///
394 + /// Prefers the payload (our generated triggers always carry the PK in `data`,
395 + /// even for hashed rows whose wire row-id is opaque). Falls back to the wire
396 + /// `row_id` for single-key tables written by an older client that stored the key
397 + /// there; a composite key with no payload cannot be reconstructed and yields
398 + /// `None`.
399 + fn pk_bindings(
400 + table: &SyncTable,
401 + data: &Value,
402 + row_id: Option<&str>,
403 + ) -> Option<Vec<(&'static str, Box<dyn ToSql>)>> {
404 + if let Some(obj) = data.as_object() {
405 + let mut out = Vec::with_capacity(table.pk.len());
406 + let mut complete = true;
407 + for pk in table.pk {
408 + match obj.get(*pk) {
409 + Some(v) if !v.is_null() => out.push((*pk, json_to_sql(v))),
410 + _ => {
411 + complete = false;
412 + break;
413 + }
414 + }
415 + }
416 + if complete && !out.is_empty() {
417 + return Some(out);
418 + }
419 + }
420 + // Payload didn't carry the key — fall back to the wire row_id (single-PK only).
421 + match (row_id, table.pk) {
422 + (Some(id), [pk]) => Some(vec![(*pk, Box::new(id.to_string()) as Box<dyn ToSql>)]),
423 + _ => None,
424 + }
425 + }
426 +
427 + // -- exclusion --
428 +
429 + /// Whether `table`'s `exclude_where` predicate excludes this row. The predicate
430 + /// is the *include* condition; a row is excluded when it does not hold. Evaluated
431 + /// in SQLite against a one-row derived table so the same predicate text drives
432 + /// both the export trigger and this import guard. No predicate, or no payload to
433 + /// test, means not excluded.
434 + fn is_excluded(
435 + tx: &Transaction<'_>,
436 + table: &SyncTable,
437 + data: Option<&Value>,
438 + ) -> rusqlite::Result<bool> {
439 + let Some(pred) = table.exclude_where else {
440 + return Ok(false);
441 + };
442 + let Some(obj) = data.and_then(|v| v.as_object()) else {
443 + return Ok(false);
444 + };
445 +
446 + let cols = table.columns;
447 + let derived = cols
448 + .iter()
449 + .enumerate()
450 + .map(|(i, c)| format!("?{} AS {c}", i + 1))
451 + .collect::<Vec<_>>()
452 + .join(", ");
453 + // COALESCE(..., 0): a NULL predicate (e.g. NULL key) is treated as included.
454 + let sql = format!(
455 + "SELECT COALESCE(NOT ({}), 0) FROM (SELECT {derived}) AS x",
456 + pred.replace("{row}", "x")
457 + );
458 + let params: Vec<Box<dyn ToSql>> = cols.iter().map(|c| json_to_sql(field_opt(obj, c))).collect();
459 + let excluded: i64 = tx.query_row(&sql, params_slice(&params).as_slice(), |r| r.get(0))?;
460 + Ok(excluded != 0)
461 + }
462 +
463 + fn field_opt<'a>(obj: &'a serde_json::Map<String, Value>, col: &str) -> &'a Value {
464 + obj.get(col).unwrap_or(&JSON_NULL)
465 + }
466 +
467 + // -- exec helpers --
468 +
469 + fn params_slice(params: &[Box<dyn ToSql>]) -> Vec<&dyn ToSql> {
470 + params.iter().map(|b| b.as_ref()).collect()
471 + }
472 +
473 + fn exec(tx: &Transaction<'_>, sql: &str, params: &[Box<dyn ToSql>]) -> rusqlite::Result<()> {
474 + tx.execute(sql, params_slice(params).as_slice())?;
475 + Ok(())
476 + }
477 +
478 + #[cfg(test)]
479 + mod tests {
480 + use super::*;
481 + use crate::types::{hlc_legacy_floor, ChangeEntry, ChangeOp};
482 + use rusqlite::Connection;
483 + use serde_json::json;
484 +
485 + use super::super::db::configure_connection;
486 + use super::super::schema::{SyncSchema, SyncTable};
487 +
488 + fn upsert(table: &str, row_id: &str, data: Value) -> ChangeEntry {
489 + ChangeEntry {
490 + table: table.into(),
491 + op: ChangeOp::Insert,
492 + row_id: row_id.into(),
493 + timestamp: chrono::Utc::now(),
494 + hlc: hlc_legacy_floor(),
495 + data: Some(data),
496 + extra: Default::default(),
497 + }
498 + }
499 +
500 + fn delete(table: &str, row_id: &str, data: Value) -> ChangeEntry {
Lines truncated
@@ -0,0 +1,471 @@
1 + //! Content-addressed blob synchronization.
2 + //!
3 + //! Blob sync is a separate pass the scheduler interleaves *after* the metadata
4 + //! pull, and it is non-fatal: a failed blob never aborts a sync cycle. An app
5 + //! declares its blob policy via [`BlobPolicy`] — which rows own a blob, where it
6 + //! lives on disk, and how to reflect local-vs-cloud presence — and the engine
7 + //! owns the invariant machinery: dedup-aware upload, integrity-verified download,
8 + //! and atomic writes.
9 + //!
10 + //! Like [`super::sync`], the client is abstracted behind a trait
11 + //! ([`BlobTransport`]) so the engine tests end-to-end against an in-memory store,
12 + //! and `SyncKitClient` satisfies it in production.
13 +
14 + use std::future::Future;
15 + use std::path::PathBuf;
16 + use std::sync::Arc;
17 +
18 + use rusqlite::Connection;
19 + use sha2::{Digest, Sha256};
20 +
21 + use super::db::DbSource;
22 + use crate::client::SyncKitClient;
23 + use crate::error::{Result, SyncKitError};
24 +
25 + /// A content-addressed blob: its SHA-256 hash (the address), file extension, and
26 + /// plaintext size in bytes.
27 + #[derive(Debug, Clone, PartialEq, Eq)]
28 + pub struct BlobRef {
29 + pub hash: String,
30 + pub ext: String,
31 + pub size: u64,
32 + }
33 +
34 + /// A presigned upload target plus whether the server already has the blob.
35 + pub struct BlobUploadUrl {
36 + pub upload_url: String,
37 + pub already_exists: bool,
38 + }
39 +
40 + /// The blob operations the engine needs from a server. A seam for testing and
41 + /// the reference a future non-Rust SDK mirrors; [`SyncKitClient`] forwards to its
42 + /// inherent `blob_*` methods.
43 + pub trait BlobTransport: Sync {
44 + fn upload_url(
45 + &self,
46 + hash: &str,
47 + size: u64,
48 + ) -> impl Future<Output = Result<BlobUploadUrl>> + Send;
49 + fn upload(
50 + &self,
51 + hash: &str,
52 + url: &str,
53 + data: Vec<u8>,
54 + ) -> impl Future<Output = Result<()>> + Send;
55 + fn confirm(&self, hash: &str, size: u64) -> impl Future<Output = Result<()>> + Send;
56 + fn download_url(&self, hash: &str) -> impl Future<Output = Result<String>> + Send;
57 + fn download(&self, hash: &str, url: &str) -> impl Future<Output = Result<Vec<u8>>> + Send;
58 + }
59 +
60 + // The trait declares `-> impl Future + Send`; implementing with `async fn` is
61 + // equivalent (the Send bound is enforced by the trait) and reads cleaner.
62 + impl BlobTransport for SyncKitClient {
63 + async fn upload_url(&self, hash: &str, size: u64) -> Result<BlobUploadUrl> {
64 + let r = SyncKitClient::blob_upload_url(self, hash, size as i64).await?;
65 + Ok(BlobUploadUrl { upload_url: r.upload_url, already_exists: r.already_exists })
66 + }
67 + async fn upload(&self, hash: &str, url: &str, data: Vec<u8>) -> Result<()> {
68 + SyncKitClient::blob_upload(self, hash, url, data).await
69 + }
70 + async fn confirm(&self, hash: &str, size: u64) -> Result<()> {
71 + SyncKitClient::blob_confirm(self, hash, size as i64).await
72 + }
73 + async fn download_url(&self, hash: &str) -> Result<String> {
74 + SyncKitClient::blob_download_url(self, hash).await
75 + }
76 + async fn download(&self, hash: &str, url: &str) -> Result<Vec<u8>> {
77 + SyncKitClient::blob_download(self, hash, url).await
78 + }
79 + }
80 +
81 + /// Which rows own blobs, where they live, and how to reflect their presence.
82 + ///
83 + /// The three query/path methods are required; the presence hooks default to
84 + /// no-ops for an app that tracks presence purely by disk existence (GoingsOn). An
85 + /// app with an explicit presence flag (audiofiles' `cloud_only`) overrides them.
86 + pub trait BlobPolicy: Send + Sync {
87 + /// Rows whose bytes should be uploaded. May return the full candidate set —
88 + /// the engine skips any whose [`local_path`](Self::local_path) is absent.
89 + fn pending_uploads(&self, conn: &Connection) -> Result<Vec<BlobRef>>;
90 + /// Rows whose bytes are wanted. The engine skips any already present on disk.
91 + fn pending_downloads(&self, conn: &Connection) -> Result<Vec<BlobRef>>;
92 + /// The on-disk location for a blob.
93 + fn local_path(&self, blob: &BlobRef) -> PathBuf;
94 + /// Reconcile presence flags before the passes (e.g. mark missing files cloud-only).
95 + fn reconcile(&self, _conn: &Connection) -> Result<()> {
96 + Ok(())
97 + }
98 + /// Reflect that a blob's bytes are now on the server.
99 + fn on_uploaded(&self, _conn: &Connection, _blob: &BlobRef) -> Result<()> {
100 + Ok(())
101 + }
102 + /// Reflect that a blob's bytes are now on local disk.
103 + fn on_downloaded(&self, _conn: &Connection, _blob: &BlobRef) -> Result<()> {
104 + Ok(())
105 + }
106 + }
107 +
108 + /// Result of a blob sync pass.
109 + #[derive(Debug, Default, PartialEq, Eq)]
110 + pub struct BlobOutcome {
111 + pub uploaded: u64,
112 + pub downloaded: u64,
113 + }
114 +
115 + type Policy = Arc<dyn BlobPolicy>;
116 +
117 + fn join_err(e: tokio::task::JoinError) -> SyncKitError {
118 + SyncKitError::Internal(format!("blocking task failed: {e}"))
119 + }
120 +
121 + fn io_err(context: &str, e: std::io::Error) -> SyncKitError {
122 + SyncKitError::Internal(format!("blob {context}: {e}"))
123 + }
124 +
125 + /// First 8 chars of a hash for logs — panic-safe on short/multibyte input.
126 + fn short(hash: &str) -> &str {
127 + hash.get(..8).unwrap_or(hash)
128 + }
129 +
130 + /// Run a policy database method on a blocking thread with a fresh connection.
131 + async fn on_conn<F, R>(db: &DbSource, policy: &Policy, f: F) -> Result<R>
132 + where
133 + F: FnOnce(&dyn BlobPolicy, &Connection) -> Result<R> + Send + 'static,
134 + R: Send + 'static,
135 + {
136 + let db = db.clone();
137 + let policy = policy.clone();
138 + tokio::task::spawn_blocking(move || f(policy.as_ref(), &db.open()?))
139 + .await
140 + .map_err(join_err)?
141 + }
142 +
143 + /// The full blob pass: reconcile, then upload local, then download missing.
144 + /// Non-fatal by construction — per-blob failures are logged and skipped.
145 + pub async fn sync_blobs<T: BlobTransport>(
146 + db: &DbSource,
147 + client: &T,
148 + policy: &Policy,
149 + ) -> Result<BlobOutcome> {
150 + on_conn(db, policy, |p, c| p.reconcile(c)).await?;
151 + let uploaded = upload_blobs(db, client, policy).await?;
152 + let downloaded = download_blobs(db, client, policy).await?;
153 + Ok(BlobOutcome { uploaded, downloaded })
154 + }
155 +
156 + /// Upload every pending local blob the server doesn't already have.
157 + pub async fn upload_blobs<T: BlobTransport>(
158 + db: &DbSource,
159 + client: &T,
160 + policy: &Policy,
161 + ) -> Result<u64> {
162 + let pending = on_conn(db, policy, |p, c| p.pending_uploads(c)).await?;
163 + let mut uploaded = 0u64;
164 + for blob in pending {
165 + match upload_one(db, client, policy, &blob).await {
166 + Ok(true) => uploaded += 1,
167 + Ok(false) => {}
168 + Err(e) => tracing::warn!(hash = short(&blob.hash), "blob upload failed (non-fatal): {e}"),
169 + }
170 + }
171 + Ok(uploaded)
172 + }
173 +
174 + async fn upload_one<T: BlobTransport>(
175 + db: &DbSource,
176 + client: &T,
177 + policy: &Policy,
178 + blob: &BlobRef,
179 + ) -> Result<bool> {
180 + let path = policy.local_path(blob);
181 + if !tokio::fs::try_exists(&path).await.unwrap_or(false) {
182 + return Ok(false); // no local file to upload
183 + }
184 + let target = client.upload_url(&blob.hash, blob.size).await?;
185 + if target.already_exists {
186 + return Ok(true); // content-addressed dedup: already on the server
187 + }
188 + let data = tokio::fs::read(&path).await.map_err(|e| io_err("read", e))?;
189 + client.upload(&blob.hash, &target.upload_url, data).await?;
190 + client.confirm(&blob.hash, blob.size).await?;
191 +
192 + let blob = blob.clone();
193 + on_conn(db, policy, move |p, c| p.on_uploaded(c, &blob)).await?;
194 + Ok(true)
195 + }
196 +
197 + /// Download every wanted blob not already on local disk.
198 + pub async fn download_blobs<T: BlobTransport>(
199 + db: &DbSource,
200 + client: &T,
201 + policy: &Policy,
202 + ) -> Result<u64> {
203 + let pending = on_conn(db, policy, |p, c| p.pending_downloads(c)).await?;
204 + let mut downloaded = 0u64;
205 + for blob in pending {
206 + let path = policy.local_path(&blob);
207 + if tokio::fs::try_exists(&path).await.unwrap_or(false) {
208 + continue; // already have it locally
209 + }
210 + match download_one(db, client, policy, &blob).await {
211 + Ok(true) => downloaded += 1,
212 + Ok(false) => {}
213 + Err(e) => tracing::warn!(hash = short(&blob.hash), "blob download failed (non-fatal): {e}"),
214 + }
215 + }
216 + Ok(downloaded)
217 + }
218 +
219 + /// Download a single blob by reference, unconditionally (the GUI "download now"
220 + /// path). Verifies the content hash before committing the file.
221 + pub async fn download_one<T: BlobTransport>(
222 + db: &DbSource,
223 + client: &T,
224 + policy: &Policy,
225 + blob: &BlobRef,
226 + ) -> Result<bool> {
227 + let path = policy.local_path(blob);
228 + let url = client.download_url(&blob.hash).await?;
229 + let data = client.download(&blob.hash, &url).await?;
230 +
231 + // Verify the plaintext hashes to its address before it lands under that name.
232 + // The SDK's blob_download also re-verifies; this guards the store regardless
233 + // of transport (and is what the in-memory tests exercise).
234 + let actual = format!("{:x}", Sha256::digest(&data));
235 + if actual != blob.hash {
236 + return Err(SyncKitError::IntegrityFailed { expected: blob.hash.clone(), actual });
237 + }
238 +
239 + if let Some(parent) = path.parent() {
240 + tokio::fs::create_dir_all(parent).await.map_err(|e| io_err("mkdir", e))?;
241 + }
242 + // Atomic: write to a sibling temp then rename, so a crash never leaves a
243 + // half-written file under the content-addressed name.
244 + let tmp = {
245 + let mut s = path.clone().into_os_string();
246 + s.push(".downloading");
247 + PathBuf::from(s)
248 + };
249 + tokio::fs::write(&tmp, &data).await.map_err(|e| io_err("write", e))?;
250 + tokio::fs::rename(&tmp, &path).await.map_err(|e| io_err("rename", e))?;
251 +
252 + let blob = blob.clone();
253 + on_conn(db, policy, move |p, c| p.on_downloaded(c, &blob)).await?;
254 + Ok(true)
255 + }
256 +
257 + #[cfg(test)]
258 + mod tests {
259 + use super::*;
260 + use std::collections::HashMap;
261 + use std::sync::Mutex;
262 +
263 + /// In-memory content-addressed blob store standing in for the server.
264 + #[derive(Clone, Default)]
265 + struct FakeBlobs {
266 + store: Arc<Mutex<HashMap<String, Vec<u8>>>>,
267 + }
268 + impl FakeBlobs {
269 + fn put_raw(&self, hash: &str, bytes: Vec<u8>) {
270 + self.store.lock().unwrap().insert(hash.into(), bytes);
271 + }
272 + fn has(&self, hash: &str) -> bool {
273 + self.store.lock().unwrap().contains_key(hash)
274 + }
275 + }
276 + impl BlobTransport for FakeBlobs {
277 + async fn upload_url(&self, hash: &str, _size: u64) -> Result<BlobUploadUrl> {
278 + Ok(BlobUploadUrl { upload_url: "fake://put".into(), already_exists: self.has(hash) })
279 + }
280 + async fn upload(&self, hash: &str, _url: &str, data: Vec<u8>) -> Result<()> {
281 + self.store.lock().unwrap().insert(hash.to_string(), data);
282 + Ok(())
283 + }
284 + async fn confirm(&self, _hash: &str, _size: u64) -> Result<()> {
285 + Ok(())
286 + }
287 + async fn download_url(&self, hash: &str) -> Result<String> {
288 + Ok(format!("fake://get/{hash}"))
289 + }
290 + async fn download(&self, hash: &str, _url: &str) -> Result<Vec<u8>> {
291 + self.store
292 + .lock()
293 + .unwrap()
294 + .get(hash)
295 + .cloned()
296 + .ok_or_else(|| SyncKitError::Internal("blob not on server".into()))
297 + }
298 + }
299 +
300 + /// A policy backed by a `blobmeta(hash, ext, size, cloud_only)` table.
301 + struct TestPolicy {
302 + dir: PathBuf,
303 + }
304 + impl BlobPolicy for TestPolicy {
305 + fn pending_uploads(&self, conn: &Connection) -> Result<Vec<BlobRef>> {
306 + rows(conn, "SELECT hash, ext, size FROM blobmeta WHERE cloud_only = 0")
307 + }
308 + fn pending_downloads(&self, conn: &Connection) -> Result<Vec<BlobRef>> {
309 + rows(conn, "SELECT hash, ext, size FROM blobmeta WHERE cloud_only = 1")
310 + }
311 + fn local_path(&self, blob: &BlobRef) -> PathBuf {
312 + self.dir.join(format!("{}.{}", blob.hash, blob.ext))
313 + }
314 + fn reconcile(&self, conn: &Connection) -> Result<()> {
315 + // Flip cloud_only=0 rows whose file is missing to cloud_only=1.
316 + let missing = rows(conn, "SELECT hash, ext, size FROM blobmeta WHERE cloud_only = 0")?
317 + .into_iter()
318 + .filter(|b| !self.local_path(b).exists())
319 + .collect::<Vec<_>>();
320 + for b in missing {
321 + conn.execute("UPDATE blobmeta SET cloud_only = 1 WHERE hash = ?1", [&b.hash])?;
322 + }
323 + Ok(())
324 + }
325 + fn on_downloaded(&self, conn: &Connection, blob: &BlobRef) -> Result<()> {
326 + conn.execute("UPDATE blobmeta SET cloud_only = 0 WHERE hash = ?1", [&blob.hash])?;
327 + Ok(())
328 + }
329 + }
330 +
331 + fn rows(conn: &Connection, sql: &str) -> Result<Vec<BlobRef>> {
332 + let mut stmt = conn.prepare(sql)?;
333 + let out = stmt
334 + .query_map([], |r| {
335 + Ok(BlobRef {
336 + hash: r.get(0)?,
337 + ext: r.get(1)?,
338 + size: r.get::<_, i64>(2)? as u64,
339 + })
340 + })?
341 + .collect::<rusqlite::Result<Vec<_>>>()?;
342 + Ok(out)
343 + }
344 +
345 + fn hash_of(content: &[u8]) -> String {
346 + format!("{:x}", Sha256::digest(content))
347 + }
348 +
349 + fn tempdir() -> PathBuf {
350 + use std::sync::atomic::{AtomicU64, Ordering};
351 + static N: AtomicU64 = AtomicU64::new(0);
352 + let mut p = std::env::temp_dir();
353 + p.push(format!("synckit_b6_{}_{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
354 + std::fs::create_dir_all(&p).unwrap();
355 + p
356 + }
357 +
358 + fn setup(dir: &std::path::Path) -> (DbSource, Policy) {
359 + let db_path = dir.join("meta.db");
360 + let db = DbSource::path(&db_path);
361 + db.open()
362 + .unwrap()
363 + .execute_batch("CREATE TABLE blobmeta (hash TEXT PRIMARY KEY, ext TEXT, size INT, cloud_only INT);")
364 + .unwrap();
365 + let content = dir.join("content");
366 + std::fs::create_dir_all(&content).unwrap();
367 + let policy: Policy = Arc::new(TestPolicy { dir: content });
368 + (db, policy)
369 + }
370 +
371 + fn seed_meta(db: &DbSource, hash: &str, ext: &str, size: u64, cloud_only: i64) {
372 + db.open()
373 + .unwrap()
374 + .execute(
375 + "INSERT INTO blobmeta (hash, ext, size, cloud_only) VALUES (?1, ?2, ?3, ?4)",
376 + (hash, ext, size as i64, cloud_only),
377 + )
378 + .unwrap();
379 + }
380 +
381 + #[tokio::test]
382 + async fn upload_sends_local_blob_and_dedups() {
383 + let dir = tempdir();
384 + let (db, policy) = setup(&dir);
385 + let server = FakeBlobs::default();
386 + let content = b"blob-one".to_vec();
387 + let h = hash_of(&content);
388 + seed_meta(&db, &h, "wav", content.len() as u64, 0);
389 + std::fs::write(policy.local_path(&BlobRef { hash: h.clone(), ext: "wav".into(), size: 0 }), &content).unwrap();
390 +
391 + assert_eq!(upload_blobs(&db, &server, &policy).await.unwrap(), 1);
392 + assert!(server.has(&h));
393 + // Second run: server already_exists → deduped, no error, still counted handled.
394 + assert_eq!(upload_blobs(&db, &server, &policy).await.unwrap(), 1);
395 + }
396 +
397 + #[tokio::test]
398 + async fn upload_skips_when_local_file_absent() {
399 + let dir = tempdir();
400 + let (db, policy) = setup(&dir);
401 + let server = FakeBlobs::default();
402 + seed_meta(&db, &hash_of(b"nope"), "wav", 4, 0); // no file on disk
403 + assert_eq!(upload_blobs(&db, &server, &policy).await.unwrap(), 0);
404 + }
405 +
406 + #[tokio::test]
407 + async fn download_verifies_writes_and_clears_cloud_only() {
408 + let dir = tempdir();
409 + let (db, policy) = setup(&dir);
410 + let server = FakeBlobs::default();
411 + let content = b"downloaded-bytes".to_vec();
412 + let h = hash_of(&content);
413 + server.put_raw(&h, content.clone());
414 + seed_meta(&db, &h, "flac", content.len() as u64, 1);
415 +
416 + assert_eq!(download_blobs(&db, &server, &policy).await.unwrap(), 1);
417 + let path = policy.local_path(&BlobRef { hash: h.clone(), ext: "flac".into(), size: 0 });
418 + assert_eq!(std::fs::read(&path).unwrap(), content);
419 + // on_downloaded cleared cloud_only.
420 + let co: i64 = db.open().unwrap().query_row("SELECT cloud_only FROM blobmeta WHERE hash=?1", [&h], |r| r.get(0)).unwrap();
421 + assert_eq!(co, 0);
422 + // No temp file left behind.
423 + assert!(!path.with_file_name(format!("{h}.flac.downloading")).exists());
424 + }
425 +
426 + #[tokio::test]
427 + async fn download_rejects_corrupt_bytes_and_writes_nothing() {
428 + let dir = tempdir();
429 + let (db, policy) = setup(&dir);
430 + let server = FakeBlobs::default();
431 + let h = hash_of(b"the-real-content");
432 + server.put_raw(&h, b"WRONG BYTES".to_vec()); // hash(bytes) != h
433 + seed_meta(&db, &h, "wav", 16, 1);
434 +
435 + // Non-fatal: download_blobs logs and counts 0; file not written.
436 + assert_eq!(download_blobs(&db, &server, &policy).await.unwrap(), 0);
437 + let path = policy.local_path(&BlobRef { hash: h.clone(), ext: "wav".into(), size: 0 });
438 + assert!(!path.exists());
439 + // And download_one surfaces the integrity error directly.
440 + let err = download_one(&db, &server, &policy, &BlobRef { hash: h, ext: "wav".into(), size: 16 }).await;
441 + assert!(matches!(err, Err(SyncKitError::IntegrityFailed { .. })));
442 + }
443 +
444 + #[tokio::test]
445 + async fn download_skips_when_already_present() {
446 + let dir = tempdir();
447 + let (db, policy) = setup(&dir);
448 + let server = FakeBlobs::default();
449 + let content = b"already-here".to_vec();
450 + let h = hash_of(&content);
451 + seed_meta(&db, &h, "wav", content.len() as u64, 1);
452 + let path = policy.local_path(&BlobRef { hash: h.clone(), ext: "wav".into(), size: 0 });
453 + std::fs::write(&path, &content).unwrap();
454 + // Server does NOT have it; if the engine tried to download it would fail —
455 + // but it must skip because the file is present.
456 + assert_eq!(download_blobs(&db, &server, &policy).await.unwrap(), 0);
457 + }
458 +
459 + #[tokio::test]
460 + async fn reconcile_flags_missing_file_cloud_only() {
461 + let dir = tempdir();
462 + let (db, policy) = setup(&dir);
463 + let server = FakeBlobs::default();
464 + // A row claims local presence (cloud_only=0) but no file exists.
465 + let h = hash_of(b"ghost");
466 + seed_meta(&db, &h, "wav", 5, 0);
467 + sync_blobs(&db, &server, &policy).await.unwrap();
468 + let co: i64 = db.open().unwrap().query_row("SELECT cloud_only FROM blobmeta WHERE hash=?1", [&h], |r| r.get(0)).unwrap();
469 + assert_eq!(co, 1, "reconcile flips a missing-file row to cloud_only");
470 + }
471 + }
@@ -0,0 +1,332 @@
1 + //! The engine's owned SQLite connection and bookkeeping-state primitives.
2 + //!
3 + //! `SyncStore` owns a private `rusqlite` connection to the app's database file
4 + //! rather than borrowing the app's async pool, so the app's own persistence layer
5 + //! is untouched. Under WAL — which the consuming apps already use — the engine's
6 + //! short read/apply transactions coexist with the app's connections on the same
7 + //! file. This module provides the low-level pieces the rest of the engine builds
8 + //! on: opening/configuring that connection, the `sync_state` key-value store, the
9 + //! crash-safe `applying_remote` echo-suppression transaction, and device
10 + //! registration.
11 +
12 + use std::path::PathBuf;
13 + use std::sync::Arc;
14 +
15 + use rusqlite::{Connection, OptionalExtension, TransactionBehavior, functions::FunctionFlags};
16 + use sha2::{Digest, Sha256};
17 + use uuid::Uuid;
18 +
19 + use crate::error::{Result, SyncKitError};
20 + use crate::ids::DeviceId;
21 +
22 + /// Where the engine gets its SQLite connection.
23 + ///
24 + /// [`DbSource::path`] is the common case — the engine opens the file itself.
25 + /// [`DbSource::factory`] lets an app hand back a connection it configured (custom
26 + /// pragmas, shared cache, a VFS); the engine still applies its own required setup
27 + /// (WAL, `foreign_keys`, the `hash_row_id` function) on top.
28 + #[derive(Clone)]
29 + pub enum DbSource {
30 + /// Open `Connection::open(path)` on demand.
31 + Path(PathBuf),
32 + /// Call an app-provided factory to obtain a raw connection.
33 + Factory(Arc<dyn Fn() -> rusqlite::Result<Connection> + Send + Sync>),
34 + }
35 +
36 + impl std::fmt::Debug for DbSource {
37 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38 + match self {
39 + Self::Path(p) => f.debug_tuple("Path").field(p).finish(),
40 + Self::Factory(_) => f.write_str("Factory(..)"),
41 + }
42 + }
43 + }
44 +
45 + impl DbSource {
46 + /// A source that opens the SQLite file at `path`.
47 + pub fn path(path: impl Into<PathBuf>) -> Self {
48 + Self::Path(path.into())
49 + }
50 +
51 + /// A source that calls `factory` for each connection.
52 + pub fn factory(
53 + factory: impl Fn() -> rusqlite::Result<Connection> + Send + Sync + 'static,
54 + ) -> Self {
55 + Self::Factory(Arc::new(factory))
56 + }
57 +
58 + /// Open and fully configure a connection: WAL journaling, foreign-key
59 + /// enforcement, and the `hash_row_id` function the generated triggers call.
60 + ///
61 + /// This is synchronous SQLite work; callers driving it from async code should
62 + /// run it on a blocking thread (the engine's push/pull loops do).
63 + pub fn open(&self) -> Result<Connection> {
64 + let conn = match self {
65 + Self::Path(p) => Connection::open(p)?,
66 + Self::Factory(f) => f()?,
67 + };
68 + configure_connection(&conn)?;
69 + Ok(conn)
70 + }
71 + }
72 +
73 + /// Apply the engine's required per-connection setup. Idempotent.
74 + pub(crate) fn configure_connection(conn: &Connection) -> Result<()> {
75 + // WAL lets the engine's connection coexist with the app's; foreign_keys ON is
76 + // the default the apply path relaxes only for `references_unsynced` tables.
77 + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?;
78 + register_hash_row_id(conn)?;
79 + Ok(())
80 + }
81 +
82 + /// Register `hash_row_id(salt, key) -> TEXT`: lowercase hex of
83 + /// `SHA-256(salt || ":" || key)`. Deterministic, keyed by the per-vault
84 + /// `row_id_salt`, so the wire row-id of a content-bearing table never carries
85 + /// cleartext. Matches the audiofiles definition byte-for-byte so its existing
86 + /// hashed row-ids stay stable across the port.
87 + fn register_hash_row_id(conn: &Connection) -> Result<()> {
88 + conn.create_scalar_function(
89 + "hash_row_id",
90 + 2,
91 + FunctionFlags::SQLITE_DETERMINISTIC | FunctionFlags::SQLITE_UTF8,
92 + |ctx| {
93 + let salt: String = ctx.get(0)?;
94 + let key: String = ctx.get(1)?;
95 + Ok(hash_row_id(&salt, &key))
96 + },
97 + )?;
98 + Ok(())
99 + }
100 +
101 + /// The `hash_row_id` computation, exposed for the apply path (which must derive
102 + /// the same wire row-id in Rust when it needs to address a hashed row).
103 + pub(crate) fn hash_row_id(salt: &str, key: &str) -> String {
104 + let mut hasher = Sha256::new();
105 + hasher.update(salt.as_bytes());
106 + hasher.update(b":");
107 + hasher.update(key.as_bytes());
108 + let digest = hasher.finalize();
109 + let mut hex = String::with_capacity(64);
110 + for byte in digest {
111 + use std::fmt::Write;
112 + let _ = write!(hex, "{byte:02x}");
113 + }
114 + hex
115 + }
116 +
117 + // -- sync_state key-value store --
118 +
119 + /// Read a `sync_state` value, or `None` if the key is absent.
120 + pub fn get_sync_state(conn: &Connection, key: &str) -> Result<Option<String>> {
121 + conn.query_row("SELECT value FROM sync_state WHERE key = ?1", [key], |r| {
122 + r.get::<_, String>(0)
123 + })
124 + .optional()
125 + .map_err(Into::into)
126 + }
127 +
128 + /// Read a `sync_state` value, falling back to `default` when absent.
129 + pub fn get_sync_state_or(conn: &Connection, key: &str, default: &str) -> Result<String> {
130 + Ok(get_sync_state(conn, key)?.unwrap_or_else(|| default.to_string()))
131 + }
132 +
133 + /// Write a `sync_state` value, inserting or updating.
134 + pub fn set_sync_state(conn: &Connection, key: &str, value: &str) -> Result<()> {
135 + conn.execute(
136 + "INSERT INTO sync_state (key, value) VALUES (?1, ?2) \
137 + ON CONFLICT(key) DO UPDATE SET value = excluded.value",
138 + (key, value),
139 + )?;
140 + Ok(())
141 + }
142 +
143 + /// Count unpushed changelog entries.
144 + pub fn count_pending_changes(conn: &Connection) -> Result<i64> {
145 + conn.query_row("SELECT COUNT(*) FROM sync_changelog WHERE pushed = 0", [], |r| r.get(0))
146 + .map_err(Into::into)
147 + }
148 +
149 + // -- applying_remote echo-suppression --
150 +
151 + /// Run `apply` with the `applying_remote` flag raised, inside a single
152 + /// `IMMEDIATE` transaction.
153 + ///
154 + /// The flag is set to `'1'` and back to `'0'` inside the same transaction as the
155 + /// writes `apply` performs. The changelog triggers, evaluating on this same
156 + /// connection, see the uncommitted `'1'` and suppress the echo; on commit the
157 + /// persisted value is `'0'` again. If `apply` returns `Err` (or the process dies
158 + /// mid-transaction) the whole transaction — including the flag flip — rolls back,
159 + /// so the flag can never be stranded at `'1'` and silently swallow later local
160 + /// edits. That stranding is exactly the failure the apps defended against with a
161 + /// start-of-sync reset; here it is structurally impossible.
162 + pub fn with_applying_remote<T>(
163 + conn: &mut Connection,
164 + apply: impl FnOnce(&rusqlite::Transaction<'_>) -> Result<T>,
165 + ) -> Result<T> {
166 + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
167 + set_sync_state(&tx, "applying_remote", "1")?;
168 + let out = apply(&tx)?;
169 + set_sync_state(&tx, "applying_remote", "0")?;
170 + tx.commit()?;
171 + Ok(out)
172 + }
173 +
174 + /// Defensively clear a stuck `applying_remote` flag at start of sync. With
175 + /// [`with_applying_remote`] the flag cannot actually strand, but a database
176 + /// written by an older, non-transactional client might carry a stale `'1'`.
177 + pub fn clear_applying_remote(conn: &Connection) -> Result<()> {
178 + set_sync_state(conn, "applying_remote", "0")
179 + }
180 +
181 + // -- device identity --
182 +
183 + /// The registered device id, or `None` if this install has not registered yet.
184 + pub fn device_id(conn: &Connection) -> Result<Option<DeviceId>> {
185 + match get_sync_state(conn, "device_id")? {
186 + Some(s) if !s.is_empty() => {
187 + let uuid = Uuid::parse_str(&s)
188 + .map_err(|e| SyncKitError::Database(format!("malformed device_id: {e}")))?;
189 + Ok(Some(DeviceId::new(uuid)))
190 + }
191 + _ => Ok(None),
192 + }
193 + }
194 +
195 + /// Persist the registered device id.
196 + pub fn set_device_id(conn: &Connection, id: DeviceId) -> Result<()> {
197 + set_sync_state(conn, "device_id", &id.as_uuid().to_string())
198 + }
199 +
200 + #[cfg(test)]
201 + mod tests {
202 + use super::super::schema::{SyncSchema, SyncTable};
203 + use super::*;
204 +
205 + /// An in-memory DB configured like the engine would, with a one-table
206 + /// migration applied so triggers exist to exercise the flag.
207 + fn db() -> Connection {
208 + let conn = Connection::open_in_memory().unwrap();
209 + configure_connection(&conn).unwrap();
210 + conn.execute_batch("CREATE TABLE note (id TEXT PRIMARY KEY, name TEXT);")
211 + .unwrap();
212 + let schema = SyncSchema::new(vec![SyncTable::full("note", &["id", "name"])]);
213 + conn.execute_batch(&schema.migration_sql()).unwrap();
214 + conn
215 + }
216 +
217 + fn changelog_count(conn: &Connection) -> i64 {
218 + conn.query_row("SELECT COUNT(*) FROM sync_changelog", [], |r| r.get(0))
219 + .unwrap()
220 + }
221 +
222 + #[test]
223 + fn hash_row_id_matches_audiofiles_formula() {
224 + // sha256("salt:key") lowercase hex — the exact audiofiles definition.
225 + let mut h = Sha256::new();
226 + h.update(b"salt:key");
227 + let expected = format!("{:x}", h.finalize());
228 + assert_eq!(hash_row_id("salt", "key"), expected);
229 + assert_eq!(hash_row_id("salt", "key").len(), 64);
230 + // Salt actually keys the hash (not just concatenated blindly).
231 + assert_ne!(hash_row_id("a", "bc"), hash_row_id("ab", "c"));
232 + }
233 +
234 + #[test]
235 + fn hash_row_id_is_registered_and_callable_in_sql() {
236 + let conn = db();
237 + let via_sql: String = conn
238 + .query_row("SELECT hash_row_id('s', 'k')", [], |r| r.get(0))
239 + .unwrap();
240 + assert_eq!(via_sql, hash_row_id("s", "k"));
241 + }
242 +
243 + #[test]
244 + fn foreign_keys_enforced_by_default() {
245 + let conn = db();
246 + let fk: i64 = conn.query_row("PRAGMA foreign_keys", [], |r| r.get(0)).unwrap();
247 + assert_eq!(fk, 1);
248 + }
249 +
250 + #[test]
251 + fn sync_state_get_set_and_absent() {
252 + let conn = db();
253 + assert_eq!(get_sync_state(&conn, "nope").unwrap(), None);
254 + assert_eq!(get_sync_state_or(&conn, "nope", "fallback").unwrap(), "fallback");
255 + set_sync_state(&conn, "k", "v1").unwrap();
256 + assert_eq!(get_sync_state(&conn, "k").unwrap().as_deref(), Some("v1"));
257 + set_sync_state(&conn, "k", "v2").unwrap(); // upsert
258 + assert_eq!(get_sync_state(&conn, "k").unwrap().as_deref(), Some("v2"));
259 + }
260 +
261 + #[test]
262 + fn applying_remote_suppresses_capture_and_commits_flag_low() {
263 + let mut conn = db();
264 + with_applying_remote(&mut conn, |tx| {
265 + tx.execute("INSERT INTO note (id, name) VALUES ('n1', 'x')", [])?;
266 + Ok(())
267 + })
268 + .unwrap();
269 + // The write landed, but its trigger was suppressed (echo not captured)...
270 + let n: i64 = conn.query_row("SELECT COUNT(*) FROM note", [], |r| r.get(0)).unwrap();
271 + assert_eq!(n, 1);
272 + assert_eq!(changelog_count(&conn), 0);
273 + // ...and the persisted flag is back to '0'.
274 + assert_eq!(get_sync_state(&conn, "applying_remote").unwrap().as_deref(), Some("0"));
275 + }
276 +
277 + #[test]
278 + fn applying_remote_rolls_back_writes_and_flag_on_error() {
279 + let mut conn = db();
280 + let result: Result<()> = with_applying_remote(&mut conn, |tx| {
281 + tx.execute("INSERT INTO note (id, name) VALUES ('n2', 'y')", [])?;
282 + Err(SyncKitError::Internal("boom mid-apply".into())) // simulate a crash
283 + });
284 + assert!(result.is_err());
285 + // The write inside the failed transaction rolled back...
286 + let n: i64 = conn.query_row("SELECT COUNT(*) FROM note", [], |r| r.get(0)).unwrap();
287 + assert_eq!(n, 0);
288 + // ...and the flag was never stranded at '1'.
289 + assert_eq!(get_sync_state(&conn, "applying_remote").unwrap().as_deref(), Some("0"));
290 + }
291 +
292 + #[test]
293 + fn normal_write_is_captured_outside_applying_remote() {
294 + let conn = db();
295 + conn.execute("INSERT INTO note (id, name) VALUES ('n3', 'z')", [])
296 + .unwrap();
297 + assert_eq!(changelog_count(&conn), 1);
298 + }
299 +
300 + #[test]
301 + fn device_id_roundtrip() {
302 + let conn = db();
303 + assert_eq!(device_id(&conn).unwrap(), None); // seeded as '' → None
304 + let id = DeviceId::new(Uuid::from_u128(0x42));
305 + set_device_id(&conn, id).unwrap();
306 + assert_eq!(device_id(&conn).unwrap(), Some(id));
307 + }
308 +
309 + #[test]
310 + fn malformed_device_id_is_an_error_not_a_panic() {
311 + let conn = db();
312 + set_sync_state(&conn, "device_id", "not-a-uuid").unwrap();
313 + assert!(device_id(&conn).is_err());
314 + }
315 +
316 + #[test]
317 + fn salt_is_provisioned_once_and_stable_across_reruns() {
318 + let conn = Connection::open_in_memory().unwrap();
319 + configure_connection(&conn).unwrap();
320 + conn.execute_batch("CREATE TABLE samp (hash TEXT PRIMARY KEY, name TEXT);")
321 + .unwrap();
322 + let schema =
323 + SyncSchema::new(vec![SyncTable::full("samp", &["hash", "name"]).pk(&["hash"]).hashed()]);
324 + conn.execute_batch(&schema.migration_sql()).unwrap();
325 + let salt1 = get_sync_state(&conn, "row_id_salt").unwrap().unwrap();
326 + assert_eq!(salt1.len(), 64); // hex of 32 random bytes
327 + // Re-running the migration must not rotate the salt (INSERT OR IGNORE).
328 + conn.execute_batch(&schema.migration_sql()).unwrap();
329 + let salt2 = get_sync_state(&conn, "row_id_salt").unwrap().unwrap();
330 + assert_eq!(salt1, salt2);
331 + }
332 + }
@@ -0,0 +1,554 @@
1 + //! The `SyncStore` facade: the public handle that assembles the schema, the
2 + //! client, and the database into a one-call sync (`sync_now`) and an optional
3 + //! background scheduler.
4 +
5 + use std::collections::HashSet;
6 + use std::sync::Arc;
7 + use std::time::{Duration, Instant};
8 +
9 + use chrono::{DateTime, Utc};
10 +
11 + use super::blob::{sync_blobs, BlobOutcome, BlobPolicy, BlobTransport};
12 + use super::db::{
13 + clear_applying_remote, count_pending_changes, get_sync_state, get_sync_state_or,
14 + set_sync_state, DbSource,
15 + };
16 + use super::scheduler::{
17 + backoff_delay, interval_elapsed, is_auth_lost, is_subscription_required, SyncObserver, SyncState,
18 + };
19 + use super::schema::SyncSchema;
20 + use super::sync::{
21 + cleanup_changelog, create_initial_snapshot, enforce_changelog_retention,
22 + ensure_device_registered, pull_changes, push_changes, SyncTransport,
23 + };
24 + use crate::client::SyncKitClient;
25 + use crate::error::{Result, SyncKitError};
26 +
27 + fn join_err(e: tokio::task::JoinError) -> SyncKitError {
28 + SyncKitError::Internal(format!("blocking task failed: {e}"))
29 + }
30 +
31 + /// Tunables for a [`SyncStore`].
32 + #[derive(Debug, Clone)]
33 + pub struct SyncConfig {
34 + /// How often the scheduler wakes to consider syncing (the timer granularity;
35 + /// the actual sync cadence is the `sync_interval_minutes` sync-state value).
36 + pub check_interval: Duration,
37 + /// Changelog size cap enforced after each cycle (`None` = no cap).
38 + pub retention_cap: Option<i64>,
39 + }
40 +
41 + impl Default for SyncConfig {
42 + fn default() -> Self {
43 + Self { check_interval: Duration::from_secs(60), retention_cap: None }
44 + }
45 + }
46 +
47 + /// Outcome of one [`SyncStore::sync_now`] cycle.
48 + #[derive(Debug, Default, PartialEq, Eq)]
49 + pub struct SyncOutcome {
50 + pub pushed: u64,
51 + pub pulled: u64,
52 + pub changed_tables: HashSet<String>,
53 + pub blobs: BlobOutcome,
54 + }
55 +
56 + /// The engine handle. Generic over the transport so it can be driven by the real
57 + /// [`SyncKitClient`] or a test double; the scheduler loop is available only for
58 + /// the concrete client (it needs SSE and auth state).
59 + pub struct SyncStore<C> {
60 + db: DbSource,
61 + client: Arc<C>,
62 + schema: SyncSchema,
63 + device_name: String,
64 + platform: String,
65 + config: SyncConfig,
66 + blob_policy: Option<Arc<dyn BlobPolicy>>,
67 + }
68 +
69 + /// Builder for [`SyncStore`].
70 + pub struct SyncStoreBuilder<C> {
71 + db: DbSource,
72 + client: C,
73 + schema: SyncSchema,
74 + device_name: String,
75 + platform: String,
76 + config: SyncConfig,
77 + blob_policy: Option<Arc<dyn BlobPolicy>>,
78 + }
79 +
80 + fn default_device_name() -> String {
81 + std::env::var("HOSTNAME")
82 + .or_else(|_| std::env::var("COMPUTERNAME"))
83 + .unwrap_or_else(|_| "SyncKit Device".to_string())
84 + }
85 +
86 + impl<C> SyncStore<C> {
87 + /// Start building a store from its database, client, and schema.
88 + pub fn builder(db: DbSource, client: C, schema: SyncSchema) -> SyncStoreBuilder<C> {
89 + SyncStoreBuilder {
90 + db,
91 + client,
92 + schema,
93 + device_name: default_device_name(),
94 + platform: std::env::consts::OS.to_string(),
95 + config: SyncConfig::default(),
96 + blob_policy: None,
97 + }
98 + }
99 +
100 + /// The engine's device name (for display / registration).
101 + pub fn device_name(&self) -> &str {
102 + &self.device_name
103 + }
104 + }
105 +
106 + impl<C> SyncStoreBuilder<C> {
107 + /// Override the device name and platform used at registration.
108 + pub fn device(mut self, name: impl Into<String>, platform: impl Into<String>) -> Self {
109 + self.device_name = name.into();
110 + self.platform = platform.into();
111 + self
112 + }
113 +
114 + /// Override the config.
115 + pub fn config(mut self, config: SyncConfig) -> Self {
116 + self.config = config;
117 + self
118 + }
119 +
120 + /// Attach a blob policy (enables the blob pass in each cycle).
121 + pub fn blob_policy(mut self, policy: Arc<dyn BlobPolicy>) -> Self {
122 + self.blob_policy = Some(policy);
123 + self
124 + }
125 +
126 + /// Finish building.
127 + pub fn build(self) -> SyncStore<C> {
128 + SyncStore {
129 + db: self.db,
130 + client: Arc::new(self.client),
131 + schema: self.schema,
132 + device_name: self.device_name,
133 + platform: self.platform,
134 + config: self.config,
135 + blob_policy: self.blob_policy,
136 + }
137 + }
138 + }
139 +
140 + impl<C: SyncTransport + BlobTransport> SyncStore<C> {
141 + /// Run one full sync cycle: ensure the device is registered, take the initial
142 + /// snapshot if needed, push local changes, pull and apply remote changes, sync
143 + /// blobs (if a policy is set), then clean up and stamp the last-sync time.
144 + pub async fn sync_now(&self) -> Result<SyncOutcome> {
145 + // Defensive: a database written by an older client might carry a stale flag.
146 + self.blocking(clear_applying_remote).await?;
147 +
148 + let device_id =
149 + ensure_device_registered(&self.db, &*self.client, &self.device_name, &self.platform)
150 + .await?;
151 + self.maybe_snapshot().await?;
152 +
153 + let pushed = push_changes(&self.db, &*self.client, device_id).await?;
154 + let pull = pull_changes(&self.db, &*self.client, &self.schema, device_id).await?;
155 + let blobs = match &self.blob_policy {
156 + Some(policy) => sync_blobs(&self.db, &*self.client, policy).await?,
157 + None => BlobOutcome::default(),
158 + };
159 +
160 + self.finalize().await?;
161 + Ok(SyncOutcome {
162 + pushed,
163 + pulled: pull.applied,
164 + changed_tables: pull.changed_tables,
165 + blobs,
166 + })
167 + }
168 +
169 + /// Count unpushed local changes.
170 + pub async fn pending_changes(&self) -> Result<i64> {
171 + self.blocking(count_pending_changes).await
172 + }
173 +
174 + async fn maybe_snapshot(&self) -> Result<()> {
175 + let db = self.db.clone();
176 + let schema = self.schema.clone();
177 + tokio::task::spawn_blocking(move || -> Result<()> {
178 + let conn = db.open()?;
179 + if get_sync_state_or(&conn, "initial_snapshot_done", "0")? != "1" {
180 + create_initial_snapshot(&conn, &schema)?;
181 + }
182 + Ok(())
183 + })
184 + .await
185 + .map_err(join_err)?
186 + }
187 +
188 + async fn finalize(&self) -> Result<()> {
189 + let db = self.db.clone();
190 + let cap = self.config.retention_cap;
191 + tokio::task::spawn_blocking(move || -> Result<()> {
192 + let conn = db.open()?;
193 + cleanup_changelog(&conn)?;
194 + if let Some(c) = cap {
195 + enforce_changelog_retention(&conn, c)?;
196 + }
197 + set_sync_state(&conn, "last_sync_at", &Utc::now().to_rfc3339())?;
198 + Ok(())
199 + })
200 + .await
201 + .map_err(join_err)?
202 + }
203 +
204 + /// Run a synchronous DB closure on a blocking thread with a fresh connection.
205 + async fn blocking<F, R>(&self, f: F) -> Result<R>
206 + where
207 + F: FnOnce(&rusqlite::Connection) -> Result<R> + Send + 'static,
208 + R: Send + 'static,
209 + {
210 + let db = self.db.clone();
211 + tokio::task::spawn_blocking(move || f(&db.open()?))
212 + .await
213 + .map_err(join_err)?
214 + }
215 + }
216 +
217 + /// Handle to a running background scheduler. Dropping it does not stop the loop;
218 + /// call [`stop`](Self::stop).
219 + pub struct SchedulerHandle {
220 + handle: tokio::task::JoinHandle<()>,
221 + }
222 +
223 + impl SchedulerHandle {
224 + /// Stop the scheduler.
225 + pub fn stop(self) {
226 + self.handle.abort();
227 + }
228 + }
229 +
230 + // The scheduler and session teardown need the concrete client's SSE stream and
231 + // auth state, so they are not part of the generic impl.
232 + impl SyncStore<SyncKitClient> {
233 + /// Spawn the background sync loop, reporting through `observer`. The loop
234 + /// races a timer against the server's change notifications, applies the gate
235 + /// chain (authenticated, key loaded, auto-sync enabled, not backed off), and
236 + /// backs off exponentially on failure (an hour on a subscription block).
237 + pub fn spawn_scheduler(
238 + self: Arc<Self>,
239 + observer: Arc<dyn SyncObserver>,
240 + ) -> SchedulerHandle {
241 + let handle = tokio::spawn(async move { self.run_scheduler(observer).await });
242 + SchedulerHandle { handle }
243 + }
244 +
245 + async fn run_scheduler(self: Arc<Self>, observer: Arc<dyn SyncObserver>) {
246 + let mut failures: u32 = 0;
247 + let mut backoff_until: Option<Instant> = None;
248 + let mut ticker = tokio::time::interval(self.config.check_interval);
249 + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
250 + // The SDK's stream auto-reconnects internally; None means SSE is
251 + // unavailable and we fall back to the timer alone.
252 + let mut sse = self.client.subscribe().await.ok();
253 +
254 + loop {
255 + let mut sse_triggered = false;
256 + match sse.as_mut() {
257 + Some(stream) => {
258 + tokio::select! {
259 + _ = ticker.tick() => {}
260 + change = stream.next_change() => match change {
261 + Some(()) => sse_triggered = true,
262 + None => sse = None, // stream ended fatally → timer-only
263 + }
264 + }
265 + }
266 + None => {
267 + ticker.tick().await;
268 + }
269 + }
270 +
271 + // Gate chain.
272 + if self.client.session_info().is_none() || self.client.is_token_expired() {
273 + observer.on_status(SyncState::LoggedOut);
274 + continue;
275 + }
276 + if !self.client.has_master_key() {
277 + continue;
278 + }
279 + if !self.auto_sync_enabled().await.unwrap_or(false) {
280 + continue;
281 + }
282 + if backoff_until.is_some_and(|u| Instant::now() < u) {
283 + continue;
284 + }
285 + if !sse_triggered && !self.timer_due().await.unwrap_or(true) {
286 + continue;
287 + }
288 +
289 + observer.on_status(SyncState::Syncing);
290 + match self.sync_now().await {
291 + Ok(outcome) => {
292 + failures = 0;
293 + backoff_until = None;
294 + observer.on_status(SyncState::Idle);
295 + if outcome.pulled > 0 {
296 + observer.on_changes_applied(&outcome.changed_tables);
297 + }
298 + }
299 + Err(e) if is_subscription_required(&e) => {
300 + observer.on_subscription_required();
301 + backoff_until = Some(Instant::now() + Duration::from_secs(3600));
302 + }
303 + Err(e) if is_auth_lost(&e) => {
304 + observer.on_status(SyncState::LoggedOut);
305 + backoff_until = Some(Instant::now() + backoff_delay(failures));
306 + }
307 + Err(e) => {
308 + failures = failures.saturating_add(1);
309 + observer.on_status(SyncState::Error(e.to_string()));
310 + backoff_until = Some(Instant::now() + backoff_delay(failures));
311 + }
312 + }
313 + }
314 + }
315 +
316 + /// Clear the in-memory session and master key. Does not touch the OS keychain
317 + /// or the local database (a later re-auth reuses the stored `device_id`).
318 + pub fn disconnect(&self) {
319 + self.client.clear_session();
320 + }
321 +
322 + async fn auto_sync_enabled(&self) -> Result<bool> {
323 + self.blocking(|c| Ok(get_sync_state_or(c, "auto_sync_enabled", "1")? == "1"))
324 + .await
325 + }
326 +
327 + async fn timer_due(&self) -> Result<bool> {
328 + self.blocking(|c| {
329 + let interval: i64 = get_sync_state_or(c, "sync_interval_minutes", "15")?
330 + .parse()
331 + .unwrap_or(15);
332 + let last = get_sync_state(c, "last_sync_at")?
333 + .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
334 + .map(|d| d.with_timezone(&Utc));
335 + Ok(interval_elapsed(last, interval, Utc::now()))
336 + })
337 + .await
338 + }
339 + }
340 +
341 + #[cfg(test)]
342 + mod tests {
343 + use super::super::blob::{BlobTransport, BlobUploadUrl};
344 + use super::super::schema::SyncTable;
345 + use super::*;
346 + use crate::ids::{AppId, DeviceId, UserId};
347 + use crate::types::{ChangeEntry, Device, PulledChange};
348 + use std::collections::HashMap;
349 + use std::future::Future;
350 + use std::path::PathBuf;
351 + use std::sync::Mutex;
352 +
353 + /// A combined fake implementing both transports over a shared changelog log
354 + /// and blob store, tagged with this device's registered id.
355 + #[derive(Clone)]
356 + struct Fake {
357 + device_id: DeviceId,
358 + log: Arc<Mutex<Vec<(DeviceId, ChangeEntry)>>>,
359 + blobs: Arc<Mutex<HashMap<String, Vec<u8>>>>,
360 + }
361 + impl Fake {
362 + fn new(device_id: u128, log: Arc<Mutex<Vec<(DeviceId, ChangeEntry)>>>) -> Self {
363 + Self {
364 + device_id: DeviceId::new(uuid::Uuid::from_u128(device_id)),
365 + log,
366 + blobs: Arc::new(Mutex::new(HashMap::new())),
367 + }
368 + }
369 + }
370 +
371 + impl SyncTransport for Fake {
372 + fn register_device(
373 + &self,
374 + _name: &str,
375 + _platform: &str,
376 + ) -> impl Future<Output = Result<Device>> + Send {
377 + let id = self.device_id;
378 + async move {
379 + Ok(Device {
380 + id,
381 + app_id: AppId::nil(),
382 + user_id: UserId::nil(),
383 + device_name: "fake".into(),
384 + platform: "test".into(),
385 + last_seen_at: Utc::now(),
386 + created_at: Utc::now(),
387 + })
388 + }
389 + }
390 + fn push(
391 + &self,
392 + device_id: DeviceId,
393 + changes: Vec<ChangeEntry>,
394 + ) -> impl Future<Output = Result<i64>> + Send {
395 + let log = self.log.clone();
396 + async move {
397 + let mut l = log.lock().unwrap();
398 + for c in changes {
399 + l.push((device_id, c));
400 + }
401 + Ok(l.len() as i64)
402 + }
403 + }
404 + fn pull_rich(
405 + &self,
406 + _device_id: DeviceId,
407 + cursor: i64,
408 + ) -> impl Future<Output = Result<(Vec<PulledChange>, i64, bool)>> + Send {
409 + let log = self.log.clone();
410 + async move {
411 + let l = log.lock().unwrap();
412 + let out: Vec<PulledChange> = l
413 + .iter()
414 + .enumerate()
415 + .filter(|(i, _)| (*i as i64 + 1) > cursor)
416 + .map(|(i, (d, e))| PulledChange { entry: e.clone(), device_id: *d, seq: i as i64 + 1 })
417 + .collect();
418 + Ok((out, l.len() as i64, false))
419 + }
420 + }
421 + }
422 +
423 + impl BlobTransport for Fake {
424 + async fn upload_url(&self, hash: &str, _size: u64) -> Result<BlobUploadUrl> {
425 + let exists = self.blobs.lock().unwrap().contains_key(hash);
426 + Ok(BlobUploadUrl { upload_url: "fake".into(), already_exists: exists })
427 + }
428 + async fn upload(&self, hash: &str, _url: &str, data: Vec<u8>) -> Result<()> {
429 + self.blobs.lock().unwrap().insert(hash.to_string(), data);
430 + Ok(())
431 + }
432 + async fn confirm(&self, _hash: &str, _size: u64) -> Result<()> {
433 + Ok(())
434 + }
435 + async fn download_url(&self, hash: &str) -> Result<String> {
436 + Ok(format!("fake://{hash}"))
437 + }
438 + async fn download(&self, hash: &str, _url: &str) -> Result<Vec<u8>> {
439 + self.blobs
440 + .lock()
441 + .unwrap()
442 + .get(hash)
443 + .cloned()
444 + .ok_or_else(|| SyncKitError::Internal("no blob".into()))
445 + }
446 + }
447 +
448 + fn schema() -> SyncSchema {
449 + SyncSchema::new(vec![SyncTable::full("note", &["id", "name"])])
450 + }
451 +
452 + fn tempdir() -> PathBuf {
453 + use std::sync::atomic::{AtomicU64, Ordering};
454 + static N: AtomicU64 = AtomicU64::new(0);
455 + let mut p = std::env::temp_dir();
456 + p.push(format!("synckit_b7_{}_{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
457 + std::fs::create_dir_all(&p).unwrap();
458 + p
459 + }
460 +
461 + fn make_db(path: &std::path::Path) -> DbSource {
462 + let db = DbSource::path(path);
463 + let conn = db.open().unwrap();
464 + conn.execute_batch("CREATE TABLE note (id TEXT PRIMARY KEY, name TEXT);").unwrap();
465 + conn.execute_batch(&schema().migration_sql()).unwrap();
466 + db
467 + }
468 +
469 + fn store(db: DbSource, fake: Fake) -> SyncStore<Fake> {
470 + SyncStore::builder(db, fake, schema()).device("dev", "test").build()
471 + }
472 +
473 + /// Pre-stamp a device's pending edit at a controlled time so cross-device HLC
474 + /// ordering is deterministic (sync_now's internal stamp is then a no-op).
475 + fn stamp_pending(db: &DbSource, node: DeviceId, now_ms: i64) {
476 + super::super::hlc::stamp_pending(&db.open().unwrap(), node, now_ms).unwrap();
477 + }
478 +
479 + #[tokio::test]
480 + async fn sync_now_registers_snapshots_pushes_and_finalizes() {
481 + let dir = tempdir();
482 + let db = make_db(&dir.join("a.db"));
483 + // Pre-existing row (trigger fired), plus clear changelog to test snapshot.
484 + {
485 + let c = db.open().unwrap();
486 + c.execute("INSERT INTO note (id, name) VALUES ('r', 'v')", []).unwrap();
487 + c.execute("DELETE FROM sync_changelog", []).unwrap();
488 + }
489 + let log = Arc::new(Mutex::new(Vec::new()));
490 + let s = store(db.clone(), Fake::new(0xA, log.clone()));
491 +
492 + let out = s.sync_now().await.unwrap();
493 + assert_eq!(out.pushed, 1, "the snapshot row is pushed");
494 + // device_id registered, last_sync stamped, snapshot marked done.
495 + let c = db.open().unwrap();
496 + assert!(get_sync_state(&c, "device_id").unwrap().is_some());
497 + assert_eq!(get_sync_state_or(&c, "initial_snapshot_done", "0").unwrap(), "1");
498 + assert!(!get_sync_state_or(&c, "last_sync_at", "").unwrap().is_empty());
499 + // The server received the snapshot entry.
500 + assert_eq!(log.lock().unwrap().len(), 1);
Lines truncated
@@ -0,0 +1,444 @@
1 + //! Hybrid logical clock ledger and conflict-strategy orchestration.
2 + //!
3 + //! This is the wiring between the changelog and the crate's audited conflict
4 + //! primitives ([`detect_conflicts`], [`CleanChanges::gated`](crate::conflict),
5 + //! [`resolve_lww_at`]). It owns three pieces of persistent HLC state:
6 + //!
7 + //! - the **local clock** (this device's monotonic HLC), kept in `sync_state`;
8 + //! - the **per-row stamp** on each `sync_changelog` row (`hlc_wall`/`hlc_counter`);
9 + //! - the **committed ledger** (`sync_committed_hlc`), the HLC last applied for a
10 + //! row, which the gate uses to drop a re-pulled older change.
11 + //!
12 + //! [`resolve_pull`] is the entry point: it turns a pulled batch into the resolved
13 + //! `Vec<ChangeEntry>` the apply engine writes, dispatching on the schema's
14 + //! [`ConflictStrategy`].
15 +
16 + use std::collections::HashMap;
17 +
18 + use chrono::{DateTime, Utc};
19 + use rusqlite::{Connection, OptionalExtension};
20 + use uuid::Uuid;
21 +
22 + use super::db::{get_sync_state, set_sync_state};
23 + use super::schema::{ConflictStrategy, SyncSchema};
24 + use crate::conflict::{Resolution, detect_conflicts, resolve_lww_at};
25 + use crate::error::Result;
26 + use crate::ids::DeviceId;
27 + use crate::types::{ChangeEntry, ChangeOp, Hlc, PulledChange};
28 +
29 + // -- local clock (persisted in sync_state) --
30 +
31 + /// Load this device's persisted clock, or `Hlc::zero(node)` if never set.
32 + pub fn load_clock(conn: &Connection, node: DeviceId) -> Result<Hlc> {
33 + let wall_ms = get_sync_state(conn, "hlc_wall")?
34 + .and_then(|s| s.parse::<i64>().ok())
35 + .unwrap_or(0);
36 + let counter = get_sync_state(conn, "hlc_counter")?
37 + .and_then(|s| s.parse::<u32>().ok())
38 + .unwrap_or(0);
39 + Ok(Hlc { wall_ms, counter, node })
40 + }
41 +
42 + fn save_clock(conn: &Connection, clock: &Hlc) -> Result<()> {
43 + set_sync_state(conn, "hlc_wall", &clock.wall_ms.to_string())?;
44 + set_sync_state(conn, "hlc_counter", &clock.counter.to_string())?;
45 + Ok(())
46 + }
47 +
48 + /// Mint an HLC for every unpushed changelog row that lacks one, advancing the
49 + /// local clock once per row. Returns how many rows were stamped.
50 + pub fn stamp_pending(conn: &Connection, node: DeviceId, now_ms: i64) -> Result<usize> {
51 + let ids: Vec<i64> = {
52 + let mut stmt = conn.prepare(
53 + "SELECT id FROM sync_changelog WHERE pushed = 0 AND hlc_wall IS NULL ORDER BY id",
54 + )?;
55 + stmt.query_map([], |r| r.get(0))?
56 + .collect::<rusqlite::Result<_>>()?
57 + };
58 + if ids.is_empty() {
59 + return Ok(0);
60 + }
61 + let mut clock = load_clock(conn, node)?;
62 + for id in &ids {
63 + clock = Hlc::tick(clock, now_ms, node);
64 + conn.execute(
65 + "UPDATE sync_changelog SET hlc_wall = ?1, hlc_counter = ?2 WHERE id = ?3",
66 + (clock.wall_ms, clock.counter, id),
67 + )?;
68 + }
69 + save_clock(conn, &clock)?;
70 + Ok(ids.len())
71 + }
72 +
73 + /// Advance the local clock past every observed remote HLC (monotonic merge).
74 + pub fn observe(
75 + conn: &Connection,
76 + node: DeviceId,
77 + remotes: impl IntoIterator<Item = Hlc>,
78 + now_ms: i64,
79 + ) -> Result<()> {
80 + let mut clock = load_clock(conn, node)?;
81 + let mut any = false;
82 + for remote in remotes {
83 + clock = Hlc::observe(clock, remote, now_ms, node);
84 + any = true;
85 + }
86 + if any {
87 + save_clock(conn, &clock)?;
88 + }
89 + Ok(())
90 + }
91 +
92 + // -- committed ledger --
93 +
94 + /// The HLC currently committed for a row, or `None` if never applied locally.
95 + pub fn committed_hlc(conn: &Connection, table: &str, row_id: &str) -> Result<Option<Hlc>> {
96 + conn.query_row(
97 + "SELECT hlc_wall, hlc_counter, hlc_node FROM sync_committed_hlc \
98 + WHERE table_name = ?1 AND row_id = ?2",
99 + (table, row_id),
100 + |r| {
101 + let wall_ms: i64 = r.get(0)?;
102 + let counter: i64 = r.get(1)?;
103 + let node: String = r.get(2)?;
104 + Ok((wall_ms, counter as u32, node))
105 + },
106 + )
107 + .optional()?
108 + .map(|(wall_ms, counter, node)| {
109 + let node = Uuid::parse_str(&node)
110 + .map(DeviceId::new)
111 + .map_err(|e| crate::error::SyncKitError::Database(format!("bad hlc_node: {e}")))?;
112 + Ok(Hlc { wall_ms, counter, node })
113 + })
114 + .transpose()
115 + }
116 +
117 + /// Record a row's committed HLC, advancing only (never regress the ledger).
118 + pub fn set_committed(conn: &Connection, table: &str, row_id: &str, hlc: &Hlc) -> Result<()> {
119 + if let Some(existing) = committed_hlc(conn, table, row_id)?
120 + && cmp_hlc(&existing, hlc).is_ge()
121 + {
122 + return Ok(());
123 + }
124 + conn.execute(
125 + "INSERT INTO sync_committed_hlc (table_name, row_id, hlc_wall, hlc_counter, hlc_node) \
126 + VALUES (?1, ?2, ?3, ?4, ?5) \
127 + ON CONFLICT(table_name, row_id) DO UPDATE SET \
128 + hlc_wall = excluded.hlc_wall, hlc_counter = excluded.hlc_counter, hlc_node = excluded.hlc_node",
129 + (
130 + table,
131 + row_id,
132 + hlc.wall_ms,
133 + hlc.counter,
134 + hlc.node.as_uuid().to_string(),
135 + ),
136 + )?;
137 + Ok(())
138 + }
139 +
140 + /// Advance the committed ledger for a batch of applied entries.
141 + pub fn record_committed(conn: &Connection, entries: &[ChangeEntry]) -> Result<()> {
142 + for e in entries {
143 + set_committed(conn, &e.table, &e.row_id, &e.hlc)?;
144 + }
145 + Ok(())
146 + }
147 +
148 + // -- pull resolution --
149 +
150 + /// Turn a pulled batch into the resolved changes to apply, per the schema's
151 + /// [`ConflictStrategy`].
152 + ///
153 + /// `ServerOrder` trusts the server's sequence order and returns the entries
154 + /// verbatim (last delivered wins). `HybridLogicalClock` runs the full pipeline:
155 + /// advance the clock past the remote HLCs, stamp local pending edits, split into
156 + /// clean vs conflicting against local pending, HLC-gate the clean set against the
157 + /// committed ledger, resolve conflicts by `resolve_lww`, and collapse to one
158 + /// entry per row (highest HLC wins, operation-agnostic).
159 + pub fn resolve_pull(
160 + conn: &Connection,
161 + schema: &SyncSchema,
162 + node: DeviceId,
163 + pulled: Vec<PulledChange>,
164 + now: DateTime<Utc>,
165 + ) -> Result<Vec<ChangeEntry>> {
166 + match schema.conflict {
167 + ConflictStrategy::ServerOrder => Ok(pulled.into_iter().map(|p| p.entry).collect()),
168 + ConflictStrategy::HybridLogicalClock => {
169 + let now_ms = now.timestamp_millis();
170 + observe(conn, node, pulled.iter().map(|p| p.entry.hlc), now_ms)?;
171 + stamp_pending(conn, node, now_ms)?;
172 +
173 + let local_pending = load_local_pending(conn, node)?;
174 + let (clean, conflicts) = detect_conflicts(pulled, &local_pending, node);
175 +
176 + let mut resolved: Vec<ChangeEntry> =
177 + clean.gated_at(now, |t, r| lookup_committed(conn, t, r));
178 +
179 + for pair in conflicts {
180 + match resolve_lww_at(&pair.local, &pair.remote, now) {
181 + Resolution::KeepRemote => resolved.push(pair.remote.entry),
182 + Resolution::KeepLocal | Resolution::Skip => {}
183 + Resolution::Merged(data) => {
184 + let hlc = max_hlc(pair.local.hlc, pair.remote.entry.hlc);
185 + resolved.push(ChangeEntry {
186 + table: pair.remote.entry.table,
187 + op: ChangeOp::Update,
188 + row_id: pair.remote.entry.row_id,
189 + timestamp: pair.remote.entry.timestamp,
190 + hlc,
191 + data: Some(data),
192 + extra: Default::default(),
193 + });
194 + }
195 + }
196 + }
197 +
198 + Ok(collapse_max_hlc(resolved))
199 + }
200 + }
201 + }
202 +
203 + /// Committed-HLC lookup for the gate. A read error is logged and treated as
204 + /// "never applied" — the gate then keeps the change, which the idempotent apply
205 + /// path can safely re-run.
206 + fn lookup_committed(conn: &Connection, table: &str, row_id: &str) -> Option<Hlc> {
207 + match committed_hlc(conn, table, row_id) {
208 + Ok(v) => v,
209 + Err(e) => {
210 + tracing::warn!(table, row_id, "committed_hlc lookup failed, treating as unset: {e}");
211 + None
212 + }
213 + }
214 + }
215 +
216 + /// Load the unpushed, HLC-stamped local changelog rows as `ChangeEntry` values
217 + /// for conflict detection. `node` is this device's id — the node of every local
218 + /// stamp.
219 + fn load_local_pending(conn: &Connection, node: DeviceId) -> Result<Vec<ChangeEntry>> {
220 + let mut stmt = conn.prepare(
221 + "SELECT table_name, op, row_id, data, hlc_wall, hlc_counter \
222 + FROM sync_changelog WHERE pushed = 0 AND hlc_wall IS NOT NULL ORDER BY id",
223 + )?;
224 + let rows = stmt.query_map([], |r| {
225 + let table: String = r.get(0)?;
226 + let op: String = r.get(1)?;
227 + let row_id: String = r.get(2)?;
228 + let data: Option<String> = r.get(3)?;
229 + let wall_ms: i64 = r.get(4)?;
230 + let counter: i64 = r.get(5)?;
231 + Ok((table, op, row_id, data, wall_ms, counter as u32))
232 + })?;
233 +
234 + let mut out = Vec::new();
235 + for row in rows {
236 + let (table, op, row_id, data, wall_ms, counter) = row?;
237 + let Some(op) = ChangeOp::from_str_opt(&op) else {
238 + continue; // unknown op — not a resolvable local edit
239 + };
240 + let data = match data {
241 + Some(s) => Some(serde_json::from_str(&s)?),
242 + None => None,
243 + };
244 + out.push(ChangeEntry {
245 + table,
246 + op,
247 + row_id,
248 + timestamp: Utc::now(),
249 + hlc: Hlc { wall_ms, counter, node },
250 + data,
251 + extra: Default::default(),
252 + });
253 + }
254 + Ok(out)
255 + }
256 +
257 + /// Collapse to one entry per `(table, row_id)`, keeping the highest HLC —
258 + /// operation-agnostic, so a newer delete beats an older edit and vice versa, and
259 + /// every device converges on the same winner. First-seen order is preserved
260 + /// (apply re-orders by schema anyway).
261 + fn collapse_max_hlc(entries: Vec<ChangeEntry>) -> Vec<ChangeEntry> {
262 + let mut best: HashMap<(String, String), usize> = HashMap::new();
263 + let mut kept: Vec<ChangeEntry> = Vec::new();
264 + for e in entries {
265 + let key = (e.table.clone(), e.row_id.clone());
266 + match best.get(&key) {
267 + Some(&i) if cmp_hlc(&kept[i].hlc, &e.hlc).is_ge() => {}
268 + Some(&i) => kept[i] = e,
269 + None => {
270 + best.insert(key, kept.len());
271 + kept.push(e);
272 + }
273 + }
274 + }
275 + kept
276 + }
277 +
278 + fn cmp_hlc(a: &Hlc, b: &Hlc) -> std::cmp::Ordering {
279 + (a.wall_ms, a.counter, a.node.as_uuid())
280 + .cmp(&(b.wall_ms, b.counter, b.node.as_uuid()))
281 + }
282 +
283 + fn max_hlc(a: Hlc, b: Hlc) -> Hlc {
284 + if cmp_hlc(&a, &b).is_ge() { a } else { b }
285 + }
286 +
287 + #[cfg(test)]
288 + mod tests {
289 + use super::super::apply::apply_remote_changes;
290 + use super::super::db::configure_connection;
291 + use super::super::schema::{SyncSchema, SyncTable};
292 + use super::*;
293 + use rusqlite::Connection;
294 +
295 + fn node(n: u128) -> DeviceId {
296 + DeviceId::new(Uuid::from_u128(n))
297 + }
298 +
299 + fn schema() -> SyncSchema {
300 + SyncSchema::new(vec![SyncTable::full("note", &["id", "name"])])
301 + }
302 +
303 + fn server_order_schema() -> SyncSchema {
304 + schema().conflict_strategy(ConflictStrategy::ServerOrder)
305 + }
306 +
307 + /// A device: in-memory DB with the note table + migration, and a node id.
308 + fn device(n: u128) -> (Connection, DeviceId) {
309 + let conn = Connection::open_in_memory().unwrap();
310 + configure_connection(&conn).unwrap();
311 + conn.execute_batch("CREATE TABLE note (id TEXT PRIMARY KEY, name TEXT);")
312 + .unwrap();
313 + conn.execute_batch(&schema().migration_sql()).unwrap();
314 + (conn, node(n))
315 + }
316 +
317 + /// Make a local edit (domain write → trigger captures it), stamp it at
318 + /// `now_ms`, and return it as a pulled change from `node` (as a peer would
319 + /// receive it via the server).
320 + fn local_edit_as_pulled(
321 + conn: &Connection,
322 + node: DeviceId,
323 + id: &str,
324 + name: &str,
325 + now_ms: i64,
326 + seq: i64,
327 + ) -> PulledChange {
328 + conn.execute(
329 + "INSERT INTO note (id, name) VALUES (?1, ?2) \
330 + ON CONFLICT(id) DO UPDATE SET name = excluded.name",
331 + (id, name),
332 + )
333 + .unwrap();
334 + stamp_pending(conn, node, now_ms).unwrap();
335 + let entry = load_local_pending(conn, node)
336 + .unwrap()
337 + .into_iter()
338 + .find(|e| e.row_id == id)
339 + .unwrap();
340 + PulledChange { entry, device_id: node, seq }
341 + }
342 +
343 + fn note_name(conn: &Connection, id: &str) -> Option<String> {
344 + conn.query_row("SELECT name FROM note WHERE id = ?1", [id], |r| r.get(0))
345 + .optional()
346 + .unwrap()
347 + }
348 +
349 + fn pull_apply(conn: &mut Connection, s: &SyncSchema, node: DeviceId, pulled: Vec<PulledChange>) {
350 + let now = Utc::now();
351 + let resolved = resolve_pull(conn, s, node, pulled, now).unwrap();
352 + apply_remote_changes(conn, s, &resolved).unwrap();
353 + record_committed(conn, &resolved).unwrap();
354 + }
355 +
356 + #[test]
357 + fn stamp_pending_assigns_monotonic_hlcs() {
358 + let (conn, n) = device(1);
359 + conn.execute("INSERT INTO note (id, name) VALUES ('a', '1')", []).unwrap();
360 + conn.execute("INSERT INTO note (id, name) VALUES ('b', '2')", []).unwrap();
361 + assert_eq!(stamp_pending(&conn, n, 1000).unwrap(), 2);
362 + // Re-stamping is a no-op (both already stamped).
363 + assert_eq!(stamp_pending(&conn, n, 2000).unwrap(), 0);
364 + let stamps: Vec<(i64, i64)> = {
365 + let mut s = conn.prepare("SELECT hlc_wall, hlc_counter FROM sync_changelog ORDER BY id").unwrap();
366 + s.query_map([], |r| Ok((r.get(0)?, r.get(1)?))).unwrap().map(|r| r.unwrap()).collect()
367 + };
368 + assert!(stamps.iter().all(|(w, _)| *w == 1000));
369 + assert_eq!(stamps[0].1 + 1, stamps[1].1, "counter increments within a wall_ms");
370 + }
371 +
372 + #[test]
373 + fn committed_ledger_advances_only() {
374 + let (conn, _) = device(1);
375 + let older = Hlc { wall_ms: 100, counter: 0, node: node(9) };
376 + let newer = Hlc { wall_ms: 200, counter: 0, node: node(9) };
377 + set_committed(&conn, "note", "r", &newer).unwrap();
378 + set_committed(&conn, "note", "r", &older).unwrap(); // must not regress
379 + assert_eq!(committed_hlc(&conn, "note", "r").unwrap().unwrap().wall_ms, 200);
380 + }
381 +
382 + #[test]
383 + fn conflicting_edits_converge_to_higher_hlc_both_directions() {
384 + // A edits at t=100, B edits at t=200 → B wins everywhere.
385 + let (mut a, an) = device(1);
386 + let (mut b, bn) = device(2);
387 + let a_change = local_edit_as_pulled(&a, an, "r", "from-A", 100, 1);
388 + let b_change = local_edit_as_pulled(&b, bn, "r", "from-B", 200, 1);
389 +
390 + pull_apply(&mut a, &schema(), an, vec![b_change]); // A pulls B (newer) → adopts B
391 + pull_apply(&mut b, &schema(), bn, vec![a_change]); // B pulls A (older) → keeps B
392 +
393 + assert_eq!(note_name(&a, "r").as_deref(), Some("from-B"));
394 + assert_eq!(note_name(&b, "r").as_deref(), Some("from-B"));
395 + }
396 +
397 + #[test]
398 + fn gate_drops_repulled_older_change() {
399 + let (mut a, an) = device(1);
400 + let (b, bn) = device(2);
401 + // B's change is applied on A.
402 + let b_change = local_edit_as_pulled(&b, bn, "r", "v-old", 100, 1);
403 + pull_apply(&mut a, &schema(), an, vec![b_change.clone()]);
404 + // A then makes a NEWER local edit and commits it.
405 + let _a_new = local_edit_as_pulled(&a, an, "r", "v-new", 300, 2);
406 + // Mark A's edit committed (as a push would) so the gate has a committed clock.
407 + let a_pending = load_local_pending(&a, an);
408 + record_committed(&a, &a_pending.unwrap()).unwrap();
409 + // Re-pulling B's OLD change must be gated out (older than committed).
410 + let resolved = resolve_pull(&a, &schema(), an, vec![b_change], Utc::now()).unwrap();
411 + assert!(resolved.iter().all(|e| e.row_id != "r"), "stale re-pull must be gated");
412 + }
413 +
414 + #[test]
415 + fn newer_delete_beats_older_edit() {
416 + let (mut a, an) = device(1);
417 + let (b, bn) = device(2);
418 + // A has an older edit locally.
419 + local_edit_as_pulled(&a, an, "r", "edit", 100, 1);
420 + // B deletes the same row, newer.
421 + b.execute("INSERT INTO note (id, name) VALUES ('r', 'x')", []).unwrap();
422 + stamp_pending(&b, bn, 50).unwrap();
423 + b.execute("DELETE FROM note WHERE id = 'r'", []).unwrap();
424 + stamp_pending(&b, bn, 200).unwrap();
425 + let del = load_local_pending(&b, bn).unwrap().into_iter().find(|e| e.op == ChangeOp::Delete).unwrap();
426 + let pulled = PulledChange { entry: del, device_id: bn, seq: 2 };
427 + pull_apply(&mut a, &schema(), an, vec![pulled]);
428 + assert_eq!(note_name(&a, "r"), None, "newer delete wins over older edit");
429 + }
430 +
431 + #[test]
432 + fn server_order_applies_last_delivered_no_hlc() {
433 + let (mut a, an) = device(1);
434 + let s = server_order_schema();
435 + // Two pulled changes for the same row; server order = last wins, HLC ignored.
436 + let (src, sn) = device(2);
437 + let first = local_edit_as_pulled(&src, sn, "r", "first", 999, 1); // higher wall
438 + let (src2, sn2) = device(3);
439 + let second = local_edit_as_pulled(&src2, sn2, "r", "second", 1, 2); // lower wall, later seq
440 + let resolved = resolve_pull(&a, &s, an, vec![first, second], Utc::now()).unwrap();
441 + apply_remote_changes(&mut a, &s, &resolved).unwrap();
442 + assert_eq!(note_name(&a, "r").as_deref(), Some("second"), "server order: last delivered wins");
443 + }
444 + }
@@ -0,0 +1,417 @@
1 + //! Migration/trigger generation from a [`SyncSchema`].
2 + //!
3 + //! [`SyncSchema::migration_sql`] emits the full sync DDL — the `sync_state` and
4 + //! `sync_changelog` bookkeeping tables, the HLC ledger, and one
5 + //! INSERT/UPDATE/DELETE trigger set per table — derived entirely from the
6 + //! manifest. Because the trigger's `json_object(...)` projection, the row-id
7 + //! expression, and (later) the apply-side binding all read the same `columns`
8 + //! and `pk`, the historical trigger/snapshot/whitelist drift is designed out.
9 + //!
10 + //! The generated form is deliberately *uniform* rather than a byte-for-byte copy
11 + //! of any one app's hand-written triggers (which differ in naming, integer-key
12 + //! casts, and whether a DELETE stores `NULL` or the key): every DELETE stores the
13 + //! primary key in `data` so the pull side reconstructs the WHERE clause the same
14 + //! way regardless of `RowIdScheme`. The golden tests assert *behaviour* — run the
15 + //! generated triggers, mutate, inspect `sync_changelog` — not string identity.
16 +
17 + use super::schema::{DeleteMode, RowIdScheme, SyncMode, SyncSchema, SyncTable};
18 +
19 + /// Bookkeeping tables shared by every schema.
20 + const BASE_TABLES: &str = "\
21 + CREATE TABLE IF NOT EXISTS sync_state (
22 + key TEXT PRIMARY KEY NOT NULL,
23 + value TEXT NOT NULL
24 + );
25 + INSERT OR IGNORE INTO sync_state (key, value) VALUES
26 + ('device_id', ''),
27 + ('pull_cursor', '0'),
28 + ('applying_remote', '0'),
29 + ('auto_sync_enabled', '1'),
30 + ('sync_interval_minutes', '15'),
31 + ('last_sync_at', ''),
32 + ('initial_snapshot_done', '0');
33 +
34 + CREATE TABLE IF NOT EXISTS sync_changelog (
35 + id INTEGER PRIMARY KEY AUTOINCREMENT,
36 + table_name TEXT NOT NULL,
37 + op TEXT NOT NULL,
38 + row_id TEXT NOT NULL,
39 + timestamp TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
40 + data TEXT,
41 + pushed INTEGER NOT NULL DEFAULT 0,
42 + hlc_wall INTEGER,
43 + hlc_counter INTEGER
44 + );
45 + CREATE INDEX IF NOT EXISTS idx_sync_changelog_unpushed
46 + ON sync_changelog(pushed) WHERE pushed = 0;
47 +
48 + -- Committed HLC per row (conflict gating). Schema may be refined in B4.
49 + CREATE TABLE IF NOT EXISTS sync_committed_hlc (
50 + table_name TEXT NOT NULL,
51 + row_id TEXT NOT NULL,
52 + hlc_wall INTEGER NOT NULL,
53 + hlc_counter INTEGER NOT NULL,
54 + hlc_node TEXT NOT NULL,
55 + PRIMARY KEY (table_name, row_id)
56 + ) WITHOUT ROWID;
57 + ";
58 +
59 + /// Provisioned once, never synced. Only emitted when a table hashes its row-id.
60 + const SALT_SEED: &str = "\nINSERT OR IGNORE INTO sync_state (key, value) VALUES
61 + ('row_id_salt', lower(hex(randomblob(32))));\n";
62 +
63 + /// The changelog echo-suppression guard shared by every trigger.
64 + const APPLYING_GUARD: &str = "(SELECT value FROM sync_state WHERE key = 'applying_remote') != '1'";
65 +
66 + impl SyncSchema {
67 + /// Emit the complete sync DDL for this schema: bookkeeping tables, the HLC
68 + /// ledger, an optional `row_id_salt`, and every table's triggers.
69 + pub fn migration_sql(&self) -> String {
70 + let mut out = String::from(BASE_TABLES);
71 + if self.any_hashed() {
72 + out.push_str(SALT_SEED);
73 + }
74 + for table in &self.tables {
75 + out.push('\n');
76 + out.push_str(&triggers_for(table));
77 + }
78 + out
79 + }
80 + }
81 +
82 + /// The wire row-id expression for a table under the given row alias (`NEW`/`OLD`
83 + /// in a trigger, or the table name in the initial-snapshot SELECT). Shared with
84 + /// [`super::sync::create_initial_snapshot`] so the snapshot produces byte-identical
85 + /// row-ids to the triggers.
86 + pub(crate) fn row_id_expr(table: &SyncTable, alias: &str) -> String {
87 + let concat = table
88 + .pk
89 + .iter()
90 + .map(|c| format!("CAST({alias}.{c} AS TEXT)"))
91 + .collect::<Vec<_>>()
92 + .join(" || ':' || ");
93 + match table.row_id {
94 + RowIdScheme::PrimaryKey => concat,
95 + RowIdScheme::Hashed => format!(
96 + "hash_row_id((SELECT value FROM sync_state WHERE key = 'row_id_salt'), {concat})"
97 + ),
98 + }
99 + }
100 +
101 + /// A `json_object('c', ALIAS.c, ...)` projection over `cols`. Shared with the
102 + /// initial-snapshot SELECT so snapshot payloads match trigger payloads exactly.
103 + pub(crate) fn json_object(alias: &str, cols: &[&str]) -> String {
104 + let pairs = cols
105 + .iter()
106 + .map(|c| format!("'{c}', {alias}.{c}"))
107 + .collect::<Vec<_>>()
108 + .join(", ");
109 + format!("json_object({pairs})")
110 + }
111 +
112 + /// The `WHEN` clause: the applying-remote guard, plus this table's exclusion
113 + /// predicate (with `{row}` bound to `alias`) if any.
114 + fn when_clause(table: &SyncTable, alias: &str) -> String {
115 + match table.exclude_where {
116 + Some(pred) => format!("{APPLYING_GUARD} AND ({})", pred.replace("{row}", alias)),
117 + None => APPLYING_GUARD.to_string(),
118 + }
119 + }
120 +
121 + /// One `CREATE TRIGGER` statement (with a preceding idempotent DROP).
122 + fn trigger(name: &str, timing: &str, table: &str, when: &str, values: &str) -> String {
123 + format!(
124 + "DROP TRIGGER IF EXISTS {name};\n\
125 + CREATE TRIGGER {name}\n\
126 + AFTER {timing} ON {table}\n\
127 + WHEN {when}\n\
128 + BEGIN\n\
129 + INSERT INTO sync_changelog (table_name, op, row_id, data)\n\
130 + VALUES {values};\n\
131 + END;\n"
132 + )
133 + }
134 +
135 + /// The INSERT/UPDATE/DELETE trigger set for one table.
136 + fn triggers_for(table: &SyncTable) -> String {
137 + let name = table.name;
138 + let emitted = table.emitted_columns();
139 + let mut out = String::new();
140 +
141 + // INSERT — only for Full-mode tables. A PartialUpdate table never inserts
142 + // (it only reflects state changes on rows that already exist locally).
143 + if matches!(table.mode, SyncMode::Full) {
144 + let values = format!(
145 + "('{name}', 'INSERT', {}, {})",
146 + row_id_expr(table, "NEW"),
147 + json_object("NEW", &emitted),
148 + );
149 + out.push_str(&trigger(
150 + &format!("sync_{name}_insert"),
151 + "INSERT",
152 + name,
153 + &when_clause(table, "NEW"),
154 + &values,
155 + ));
156 + }
157 +
158 + // UPDATE — always. PartialUpdate additionally guards on one of its `set`
159 + // columns actually changing (null-safe `IS NOT`).
160 + let update_when = match &table.mode {
161 + SyncMode::Full => when_clause(table, "NEW"),
162 + SyncMode::PartialUpdate { set } => {
163 + let changed = set
164 + .iter()
165 + .map(|c| format!("OLD.{c} IS NOT NEW.{c}"))
166 + .collect::<Vec<_>>()
167 + .join(" OR ");
168 + format!("{} AND ({changed})", when_clause(table, "NEW"))
169 + }
170 + };
171 + let update_values = format!(
172 + "('{name}', 'UPDATE', {}, {})",
173 + row_id_expr(table, "NEW"),
174 + json_object("NEW", &emitted),
175 + );
176 + out.push_str(&trigger(
177 + &format!("sync_{name}_update"),
178 + "UPDATE",
179 + name,
180 + &update_when,
181 + &update_values,
182 + ));
183 +
184 + // DELETE — generated unless deletes are ignored. Tombstone tables still emit
185 + // a normal export trigger (a *local* delete must propagate); the tombstone is
186 + // an apply-side concern. The payload carries the primary key so the pull side
187 + // reconstructs the WHERE clause without the (possibly hashed) row-id.
188 + if !matches!(table.deletes, DeleteMode::Ignore) {
189 + let delete_values = format!(
190 + "('{name}', 'DELETE', {}, {})",
191 + row_id_expr(table, "OLD"),
192 + json_object("OLD", table.pk),
193 + );
194 + out.push_str(&trigger(
195 + &format!("sync_{name}_delete"),
196 + "DELETE",
197 + name,
198 + &when_clause(table, "OLD"),
199 + &delete_values,
200 + ));
201 + }
202 +
203 + out
204 + }
205 +
206 + #[cfg(test)]
207 + mod tests {
208 + use super::super::schema::{SyncSchema, SyncTable};
209 + use rusqlite::Connection;
210 + use sha2::{Digest, Sha256};
211 +
212 + /// A row observed in `sync_changelog`.
213 + #[derive(Debug, PartialEq, Eq)]
214 + struct Entry {
215 + table: String,
216 + op: String,
217 + row_id: String,
218 + data: Option<String>,
219 + }
220 +
221 + /// Build an in-memory DB with the domain tables the tests mutate, register a
222 + /// deterministic `hash_row_id`, then run the generated migration.
223 + fn setup(schema: &SyncSchema) -> Connection {
224 + let conn = Connection::open_in_memory().unwrap();
225 + // Deterministic stand-in for the engine's real hash_row_id (B2). Any
226 + // stable non-identity function proves the row-id was hashed, not leaked.
227 + conn.create_scalar_function(
228 + "hash_row_id",
229 + 2,
230 + rusqlite::functions::FunctionFlags::SQLITE_DETERMINISTIC,
231 + |ctx| {
232 + let salt: String = ctx.get(0)?;
233 + let key: String = ctx.get(1)?;
234 + let mut h = Sha256::new();
235 + h.update(salt.as_bytes());
236 + h.update(key.as_bytes());
237 + Ok(format!("{:x}", h.finalize()))
238 + },
239 + )
240 + .unwrap();
241 +
242 + conn.execute_batch(
243 + "
244 + CREATE TABLE note (id TEXT PRIMARY KEY, name TEXT);
245 + CREATE TABLE samp (hash TEXT PRIMARY KEY, name TEXT, deleted_at INTEGER);
246 + CREATE TABLE tags (sample_hash TEXT, tag TEXT, PRIMARY KEY (sample_hash, tag));
247 + CREATE TABLE items (id TEXT PRIMARY KEY, is_read INTEGER, is_starred INTEGER, title TEXT);
248 + CREATE TABLE cfg (key TEXT PRIMARY KEY, value TEXT);
249 + ",
250 + )
251 + .unwrap();
252 +
253 + conn.execute_batch(&schema.migration_sql()).unwrap();
254 + conn
255 + }
256 +
257 + /// The representative manifest: one table per trigger-affecting policy.
258 + fn schema() -> SyncSchema {
259 + SyncSchema::new(vec![
260 + SyncTable::full("note", &["id", "name"]),
261 + SyncTable::full("samp", &["hash", "name", "deleted_at"])
262 + .pk(&["hash"])
263 + .hashed()
264 + .tombstone("deleted_at"),
265 + SyncTable::full("tags", &["sample_hash", "tag"])
266 + .pk(&["sample_hash", "tag"])
267 + .hashed(),
268 + SyncTable::full("items", &["id", "is_read", "is_starred"])
269 + .partial_update(&["is_read", "is_starred"])
270 + .ignore_deletes(),
271 + SyncTable::full("cfg", &["key", "value"])
272 + .pk(&["key"])
273 + .exclude_where("{row}.key NOT LIKE 'sync\\_%' ESCAPE '\\'"),
274 + ])
275 + }
276 +
277 + fn changelog(conn: &Connection) -> Vec<Entry> {
278 + let mut stmt = conn
279 + .prepare("SELECT table_name, op, row_id, data FROM sync_changelog ORDER BY id")
280 + .unwrap();
281 + let rows = stmt
282 + .query_map([], |r| {
283 + Ok(Entry {
284 + table: r.get(0)?,
285 + op: r.get(1)?,
286 + row_id: r.get(2)?,
287 + data: r.get(3)?,
288 + })
289 + })
290 + .unwrap();
291 + rows.map(|r| r.unwrap()).collect()
292 + }
293 +
294 + #[test]
295 + fn full_cleartext_roundtrips_insert_update_delete() {
296 + let conn = setup(&schema());
297 + conn.execute("INSERT INTO note (id, name) VALUES ('n1', 'hello')", [])
298 + .unwrap();
299 + conn.execute("UPDATE note SET name = 'bye' WHERE id = 'n1'", [])
300 + .unwrap();
301 + conn.execute("DELETE FROM note WHERE id = 'n1'", []).unwrap();
302 +
303 + let log = changelog(&conn);
304 + assert_eq!(log.len(), 3);
305 + assert_eq!((log[0].op.as_str(), log[0].row_id.as_str()), ("INSERT", "n1"));
306 + assert!(log[0].data.as_deref().unwrap().contains("\"name\":\"hello\""));
307 + assert_eq!(log[1].op, "UPDATE");
308 + // Cleartext delete: row_id is the key, data carries the PK (not NULL).
309 + assert_eq!((log[2].op.as_str(), log[2].row_id.as_str()), ("DELETE", "n1"));
310 + assert_eq!(log[2].data.as_deref(), Some("{\"id\":\"n1\"}"));
311 + }
312 +
313 + #[test]
314 + fn applying_remote_flag_suppresses_capture() {
315 + let conn = setup(&schema());
316 + conn.execute("UPDATE sync_state SET value = '1' WHERE key = 'applying_remote'", [])
317 + .unwrap();
318 + conn.execute("INSERT INTO note (id, name) VALUES ('n2', 'x')", [])
319 + .unwrap();
320 + assert!(changelog(&conn).is_empty(), "echo must not be captured");
321 + }
322 +
323 + #[test]
324 + fn hashed_rowid_hides_key_and_delete_carries_pk() {
325 + let conn = setup(&schema());
326 + conn.execute("INSERT INTO samp (hash, name) VALUES ('deadbeef', 's')", [])
327 + .unwrap();
328 + conn.execute("DELETE FROM samp WHERE hash = 'deadbeef'", [])
329 + .unwrap();
330 +
331 + let log = changelog(&conn);
332 + assert_eq!(log.len(), 2);
333 + // The wire row-id must not be the cleartext content hash.
334 + assert_ne!(log[0].row_id, "deadbeef");
335 + assert_eq!(log[0].row_id.len(), 64, "sha256 hex");
336 + assert!(log[0].data.as_deref().unwrap().contains("\"hash\":\"deadbeef\""));
337 + // Tombstone tables STILL emit a normal export DELETE; row-id hashed, PK in data.
338 + assert_eq!(log[1].op, "DELETE");
339 + assert_eq!(log[1].row_id, log[0].row_id);
340 + assert_eq!(log[1].data.as_deref(), Some("{\"hash\":\"deadbeef\"}"));
341 + }
342 +
343 + #[test]
344 + fn hashed_composite_key_concatenates_before_hashing() {
345 + let conn = setup(&schema());
346 + conn.execute("INSERT INTO tags (sample_hash, tag) VALUES ('abc', 'kick')", [])
347 + .unwrap();
348 + let log = changelog(&conn);
349 + assert_eq!(log.len(), 1);
350 + // Composite key "abc:kick" is concatenated then hashed; both parts
351 + // survive in data. Salt is random per run, so assert shape not value.
352 + assert_eq!(log[0].row_id.len(), 64);
353 + assert!(log[0].data.as_deref().unwrap().contains("\"sample_hash\":\"abc\""));
354 + assert!(log[0].data.as_deref().unwrap().contains("\"tag\":\"kick\""));
355 + }
356 +
357 + #[test]
358 + fn partial_update_is_update_only_and_change_gated() {
359 + let conn = setup(&schema());
360 + // Insert never captured (no INSERT trigger for PartialUpdate).
361 + conn.execute("INSERT INTO items (id, is_read, is_starred, title) VALUES ('i1', 0, 0, 't')", [])
362 + .unwrap();
363 + assert!(changelog(&conn).is_empty());
364 +
365 + // Updating a non-set column does not fire (change guard on is_read/is_starred).
366 + conn.execute("UPDATE items SET title = 't2' WHERE id = 'i1'", [])
367 + .unwrap();
368 + assert!(changelog(&conn).is_empty());
369 +
370 + // Updating a set column fires exactly one UPDATE carrying pk + set cols.
371 + conn.execute("UPDATE items SET is_read = 1 WHERE id = 'i1'", [])
372 + .unwrap();
373 + let log = changelog(&conn);
374 + assert_eq!(log.len(), 1);
375 + assert_eq!((log[0].op.as_str(), log[0].row_id.as_str()), ("UPDATE", "i1"));
376 + let data = log[0].data.as_deref().unwrap();
377 + assert!(data.contains("\"is_read\":1") && data.contains("\"is_starred\":0"));
378 + }
379 +
380 + #[test]
381 + fn ignore_deletes_emits_no_delete_row() {
382 + let conn = setup(&schema());
383 + conn.execute("INSERT INTO items (id, is_read, is_starred) VALUES ('i2', 1, 0)", [])
384 + .unwrap();
385 + conn.execute("UPDATE items SET is_read = 0 WHERE id = 'i2'", [])
386 + .unwrap(); // baseline capture
387 + let before = changelog(&conn).len();
388 + conn.execute("DELETE FROM items WHERE id = 'i2'", []).unwrap();
389 + assert_eq!(changelog(&conn).len(), before, "ignored delete must not capture");
390 + }
391 +
392 + #[test]
393 + fn exclude_where_filters_symmetrically_on_export() {
394 + let conn = setup(&schema());
395 + conn.execute("INSERT INTO cfg (key, value) VALUES ('sync_cursor', '5')", [])
396 + .unwrap(); // excluded
397 + conn.execute("INSERT INTO cfg (key, value) VALUES ('theme', 'dark')", [])
398 + .unwrap(); // included
399 + let log = changelog(&conn);
400 + assert_eq!(log.len(), 1);
401 + assert_eq!(log[0].row_id, "theme");
402 + }
403 +
404 + #[test]
405 + fn salt_seeded_only_when_a_table_is_hashed() {
406 + // schema() has hashed tables → salt present.
407 + let with = setup(&schema());
408 + let salt: i64 = with
409 + .query_row("SELECT COUNT(*) FROM sync_state WHERE key = 'row_id_salt'", [], |r| r.get(0))
410 + .unwrap();
411 + assert_eq!(salt, 1);
412 +
413 + // A schema with no hashed table must not seed a salt.
414 + let plain = SyncSchema::new(vec![SyncTable::full("note", &["id", "name"])]);
415 + assert!(!plain.migration_sql().contains("row_id_salt"));
416 + }
417 + }
@@ -0,0 +1,51 @@
1 + //! Higher-level "syncable store" engine.
2 + //!
3 + //! `SyncStore` absorbs the SQLite plumbing every consuming app otherwise
4 + //! hand-writes — the changelog schema and triggers, push/pull loops, FK-ordered
5 + //! apply, HLC, blobs, and the scheduler — behind a declarative [`SyncSchema`].
6 + //! An app declares its tables and per-table policy once; the engine owns the
7 + //! mechanics. See `docs/architecture.md` ("Proposed: the `SyncStore` helper").
8 + //!
9 + //! Build status: **complete (B1–B7)** — schema types ([`schema`]), trigger/
10 + //! migration generation ([`migrate`]), the DB seam + bookkeeping state ([`db`]),
11 + //! the FK-ordered apply engine ([`apply`]), the HLC ledger + conflict dispatch
12 + //! ([`hlc`]), the push/pull loops + lifecycle ([`sync`]), the blob engine
13 + //! ([`blob`]), and the [`SyncStore`](facade::SyncStore) facade + scheduler
14 + //! ([`facade`], [`scheduler`]).
15 + //!
16 + //! Start at [`SyncStore`](facade::SyncStore): declare a [`SyncSchema`](schema),
17 + //! build a store, and call `sync_now` or `spawn_scheduler`.
18 +
19 + pub mod apply;
20 + pub mod blob;
21 + pub mod db;
22 + pub mod facade;
23 + pub mod hlc;
24 + pub mod migrate;
25 + pub mod schema;
26 + pub mod scheduler;
27 + pub mod sync;
28 +
29 + pub use apply::{apply_remote_changes, ApplyOutcome};
30 + pub use blob::{
31 + download_blobs, download_one, sync_blobs, upload_blobs, BlobOutcome, BlobPolicy, BlobRef,
32 + BlobTransport, BlobUploadUrl,
33 + };
34 + pub use facade::{SchedulerHandle, SyncConfig, SyncOutcome, SyncStore, SyncStoreBuilder};
35 + pub use scheduler::{NoopObserver, SyncObserver, SyncState};
36 + pub use hlc::{
37 + committed_hlc, load_clock, observe, record_committed, resolve_pull, set_committed,
38 + stamp_pending,
39 + };
40 + pub use sync::{
41 + cleanup_changelog, create_initial_snapshot, enforce_changelog_retention,
42 + ensure_device_registered, pull_changes, push_changes, PullOutcome, SyncTransport,
43 + PUSH_BATCH_LIMIT,
44 + };
45 + pub use db::{
46 + clear_applying_remote, device_id, get_sync_state, get_sync_state_or, set_device_id,
47 + set_sync_state, with_applying_remote, DbSource,
48 + };
49 + pub use schema::{
50 + ConflictStrategy, DeleteMode, RowIdScheme, SyncMode, SyncSchema, SyncTable,
51 + };
@@ -0,0 +1,119 @@
1 + //! Background scheduler support: the observer interface the engine reports
2 + //! through, and the small pure decisions the scheduler loop makes (backoff,
3 + //! interval-elapsed, subscription-required classification).
4 + //!
5 + //! The loop itself lives on [`SyncStore`](super::facade::SyncStore) (it needs the
6 + //! concrete client's SSE stream and auth state); the pieces here are factored out
7 + //! so they can be unit-tested in isolation.
8 +
9 + use std::collections::HashSet;
10 + use std::time::Duration;
11 +
12 + use chrono::{DateTime, Utc};
13 +
14 + use crate::error::SyncKitError;
15 +
16 + /// Coarse sync state reported to the UI. (Named to avoid colliding with the
17 + /// server-facing [`SyncStatus`](crate::types::SyncStatus).)
18 + #[derive(Debug, Clone, PartialEq, Eq)]
19 + pub enum SyncState {
20 + /// Not currently syncing; last cycle succeeded (or none has run).
21 + Idle,
22 + /// A sync cycle is in progress.
23 + Syncing,
24 + /// The last cycle failed; carries a human-readable reason.
25 + Error(String),
26 + /// Sync is blocked pending an active membership/purchase (server `402`).
27 + SubscriptionRequired,
28 + /// The session is gone (token expired / cleared); re-auth needed.
29 + LoggedOut,
30 + }
31 +
32 + /// How the engine reports progress. All methods default to no-ops, so an app
33 + /// implements only the signals it renders. Bridges any UI model (Tauri events,
34 + /// an `Arc<Mutex<_>>`, a channel).
35 + pub trait SyncObserver: Send + Sync {
36 + /// The coarse state changed.
37 + fn on_status(&self, _state: SyncState) {}
38 + /// A pull applied changes touching these tables (for selective invalidation).
39 + fn on_changes_applied(&self, _tables: &HashSet<String>) {}
40 + /// The server refused sync pending a subscription.
41 + fn on_subscription_required(&self) {}
42 + }
43 +
44 + /// An observer that discards every signal.
45 + #[derive(Debug, Default, Clone, Copy)]
46 + pub struct NoopObserver;
47 + impl SyncObserver for NoopObserver {}
48 +
49 + /// Exponential backoff after `consecutive_failures` failed cycles: 1, 2, 4, 8,
50 + /// then capped at 15 minutes. `0` failures means "no backoff" is not this — the
51 + /// first failure (count 1) waits 2 minutes; call with the post-increment count.
52 + pub fn backoff_delay(consecutive_failures: u32) -> Duration {
53 + let mins = 2u64.pow(consecutive_failures.min(4)).min(15);
54 + Duration::from_secs(mins * 60)
55 + }
56 +
57 + /// Whether enough time has elapsed since `last_sync` to sync again on the timer
58 + /// path. A missing/unparseable `last_sync` is always due.
59 + pub fn interval_elapsed(
60 + last_sync: Option<DateTime<Utc>>,
61 + interval_minutes: i64,
62 + now: DateTime<Utc>,
63 + ) -> bool {
64 + match last_sync {
65 + None => true,
66 + Some(prev) => now.signed_duration_since(prev).num_minutes() >= interval_minutes,
67 + }
68 + }
69 +
70 + /// Whether an error means the server is gating sync behind a subscription (`402`).
71 + pub fn is_subscription_required(err: &SyncKitError) -> bool {
72 + matches!(err, SyncKitError::Server { status: 402, .. })
73 + }
74 +
75 + /// Whether an error is a lost/expired session the caller should surface as
76 + /// logged-out (`401`/`403`, or the client's own token-expiry signal).
77 + pub fn is_auth_lost(err: &SyncKitError) -> bool {
78 + matches!(err, SyncKitError::TokenExpired | SyncKitError::NotAuthenticated)
79 + || matches!(err, SyncKitError::Server { status: 401 | 403, .. })
80 + }
81 +
82 + #[cfg(test)]
83 + mod tests {
84 + use super::*;
85 +
86 + #[test]
87 + fn backoff_is_exponential_then_capped() {
88 + assert_eq!(backoff_delay(0).as_secs(), 60); // 2^0 = 1 minute
89 + assert_eq!(backoff_delay(1).as_secs(), 2 * 60);
90 + assert_eq!(backoff_delay(2).as_secs(), 4 * 60);
91 + assert_eq!(backoff_delay(3).as_secs(), 8 * 60);
92 + assert_eq!(backoff_delay(4).as_secs(), 15 * 60); // 2^4=16 capped to 15
93 + assert_eq!(backoff_delay(50).as_secs(), 15 * 60); // no overflow, still capped
94 + }
95 +
96 + #[test]
97 + fn interval_elapsed_rules() {
98 + let now = Utc::now();
99 + assert!(interval_elapsed(None, 15, now), "never-synced is always due");
100 + let five_ago = now - chrono::Duration::minutes(5);
101 + assert!(!interval_elapsed(Some(five_ago), 15, now), "too soon");
102 + let twenty_ago = now - chrono::Duration::minutes(20);
103 + assert!(interval_elapsed(Some(twenty_ago), 15, now), "past the interval");
104 + }
105 +
106 + #[test]
107 + fn error_classification() {
108 + let sub = SyncKitError::Server { status: 402, message: "pay".into(), retry_after_secs: None };
109 + assert!(is_subscription_required(&sub));
110 + assert!(!is_auth_lost(&sub));
111 +
112 + assert!(is_auth_lost(&SyncKitError::TokenExpired));
113 + assert!(is_auth_lost(&SyncKitError::Server { status: 401, message: String::new(), retry_after_secs: None }));
114 + assert!(!is_subscription_required(&SyncKitError::TokenExpired));
115 +
116 + let other = SyncKitError::Internal("boom".into());
117 + assert!(!is_subscription_required(&other) && !is_auth_lost(&other));
118 + }
119 + }
@@ -0,0 +1,233 @@
1 + //! Declarative sync schema: the manifest an app hands `SyncStore` so the engine
2 + //! can own the changelog, triggers, and apply logic.
3 + //!
4 + //! An app describes *what* it syncs — tables, columns, primary keys, and the
5 + //! handful of per-table policies that genuinely vary — and the engine owns
6 + //! *how*. One `columns` list per table drives the generated trigger, the initial
7 + //! snapshot, and the apply-side binding, so the historical
8 + //! trigger/snapshot/whitelist triplication (and its silent-drift bug class)
9 + //! cannot recur.
10 + //!
11 + //! See `docs/architecture.md` ("Proposed: the `SyncStore` helper") for the full
12 + //! design and worked GoingsOn / audiofiles / Balanced Breakfast manifests.
13 +
14 + /// How the engine resolves concurrent edits to the same row.
15 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
16 + pub enum ConflictStrategy {
17 + /// Stamp a hybrid logical clock per edit, gate clean changes against
18 + /// committed clocks, and resolve true conflicts with `resolve_lww`.
19 + /// Deterministic multi-device convergence. The default.
20 + HybridLogicalClock,
21 + /// Trust the server's sequence order; last delivered wins. Simplest;
22 + /// adequate when concurrent same-row edits are rare/uninteresting.
23 + ServerOrder,
24 + }
25 +
26 + /// How a table's rows are upserted when a remote change arrives.
27 + #[derive(Debug, Clone, PartialEq, Eq)]
28 + pub enum SyncMode {
29 + /// Upsert every whitelisted column (`INSERT ... ON CONFLICT DO UPDATE`, or
30 + /// `INSERT OR IGNORE` when every column is part of the primary key).
31 + Full,
32 + /// Only ever `UPDATE` the `set` columns where the PK matches — never insert,
33 + /// never replace. The export side emits a single UPDATE trigger, gated on one
34 + /// of the `set` columns actually changing. (Balanced Breakfast's `feed_items`:
35 + /// sync just `is_read`/`is_starred`, never the article body.)
36 + PartialUpdate { set: &'static [&'static str] },
37 + }
38 +
39 + /// How a table handles a remote delete.
40 + #[derive(Debug, Clone, PartialEq, Eq)]
41 + pub enum DeleteMode {
42 + /// `DELETE FROM table WHERE pk = ...`.
43 + Hard,
44 + /// Drop remote deletes on the floor (Balanced Breakfast's `feed_items` —
45 + /// content re-fetches from the source). No DELETE trigger is generated and
46 + /// the apply path ignores deletes for this table.
47 + Ignore,
48 + /// Apply a remote delete as `UPDATE table SET <column> = now()` instead of a
49 + /// hard `DELETE`, so a local `ON DELETE CASCADE` can't wipe organizational
50 + /// state the remote delete never intended to touch (audiofiles' `samples`).
51 + /// The export DELETE trigger is still generated normally — a *local* delete
52 + /// must still propagate; only the *apply* of a remote delete tombstones.
53 + Tombstone { column: &'static str },
54 + }
55 +
56 + /// How a row's wire identifier (`sync_changelog.row_id`) is derived.
57 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
58 + pub enum RowIdScheme {
59 + /// The wire `row_id` is the primary key itself (cast to text). Use for
60 + /// opaque-integer or closed-config keys that carry no user content.
61 + PrimaryKey,
62 + /// The wire `row_id` is a salted hash of the primary key; the cleartext PK
63 + /// travels inside the (encrypted) `data` payload so the pull side can
64 + /// reconstruct the WHERE clause without the opaque hash. Use for
65 + /// content-bearing keys (content hashes, tag strings). Obliges the engine to
66 + /// own a per-vault `row_id_salt` and register the `hash_row_id` SQL function.
67 + Hashed,
68 + }
69 +
70 + /// A single syncable table and its per-table policy.
71 + ///
72 + /// Build the common case with [`SyncTable::full`] and layer policy with the
73 + /// chained setters; only a minority of tables need any setter at all.
74 + #[derive(Debug, Clone)]
75 + pub struct SyncTable {
76 + pub(crate) name: &'static str,
77 + pub(crate) columns: &'static [&'static str],
78 + pub(crate) pk: &'static [&'static str],
79 + pub(crate) row_id: RowIdScheme,
80 + pub(crate) mode: SyncMode,
81 + pub(crate) deletes: DeleteMode,
82 + pub(crate) preserve_local: &'static [&'static str],
83 + pub(crate) insert_defaults: &'static [(&'static str, &'static str)],
84 + pub(crate) references_unsynced: bool,
85 + pub(crate) exclude_where: Option<&'static str>,
86 + }
87 +
88 + impl SyncTable {
89 + /// A table with the common defaults: single `id` primary key, cleartext
90 + /// wire row-id, `Full` upsert, `Hard` delete, no preserved columns, no insert
91 + /// defaults, closed referential integrity, no row exclusion.
92 + pub fn full(name: &'static str, columns: &'static [&'static str]) -> Self {
93 + Self {
94 + name,
95 + columns,
96 + pk: &["id"],
97 + row_id: RowIdScheme::PrimaryKey,
98 + mode: SyncMode::Full,
99 + deletes: DeleteMode::Hard,
100 + preserve_local: &[],
101 + insert_defaults: &[],
102 + references_unsynced: false,
103 + exclude_where: None,
104 + }
105 + }
106 +
107 + /// Override the primary-key column(s). One element is a single PK; more is a
108 + /// composite key (concatenated with `':'` for the wire row-id).
109 + pub fn pk(mut self, pk: &'static [&'static str]) -> Self {
110 + self.pk = pk;
111 + self
112 + }
113 +
114 + /// Hash the wire row-id and carry the cleartext PK in the payload
115 + /// ([`RowIdScheme::Hashed`]).
116 + pub fn hashed(mut self) -> Self {
117 + self.row_id = RowIdScheme::Hashed;
118 + self
119 + }
120 +
121 + /// Apply remote deletes as a tombstone write to `column`
122 + /// ([`DeleteMode::Tombstone`]).
123 + pub fn tombstone(mut self, column: &'static str) -> Self {
124 + self.deletes = DeleteMode::Tombstone { column };
125 + self
126 + }
127 +
128 + /// Ignore remote deletes entirely ([`DeleteMode::Ignore`]).
129 + pub fn ignore_deletes(mut self) -> Self {
130 + self.deletes = DeleteMode::Ignore;
131 + self
132 + }
133 +
134 + /// Sync only the `set` columns via UPDATE ([`SyncMode::PartialUpdate`]).
135 + pub fn partial_update(mut self, set: &'static [&'static str]) -> Self {
136 + self.mode = SyncMode::PartialUpdate { set };
137 + self
138 + }
139 +
140 + /// Columns to leave untouched on a conflict-update (per-device secrets).
141 + pub fn preserve_local(mut self, cols: &'static [&'static str]) -> Self {
142 + self.preserve_local = cols;
143 + self
144 + }
145 +
146 + /// Values injected only on first INSERT (e.g. to satisfy a NOT NULL on a
147 + /// preserved secret column).
148 + pub fn insert_defaults(mut self, defaults: &'static [(&'static str, &'static str)]) -> Self {
149 + self.insert_defaults = defaults;
150 + self
151 + }
152 +
153 + /// This table carries a foreign key into an *unsynced* table; relax FK
154 + /// enforcement while applying it.
155 + pub fn references_unsynced(mut self) -> Self {
156 + self.references_unsynced = true;
157 + self
158 + }
159 +
160 + /// A SQL boolean predicate that must hold for a row to sync, applied
161 + /// symmetrically on export (the generated trigger's `WHEN`) and import (the
162 + /// apply guard). Reference the row through the `{row}` token — the generator
163 + /// substitutes `NEW`/`OLD` per trigger. Example:
164 + /// `"{row}.key NOT LIKE 'sync\\_%' ESCAPE '\\' AND {row}.key <> 'loose_files'"`.
165 + pub fn exclude_where(mut self, predicate: &'static str) -> Self {
166 + self.exclude_where = Some(predicate);
167 + self
168 + }
169 +
170 + /// The columns emitted into `sync_changelog.data` for an INSERT/UPDATE:
171 + /// every whitelisted column for `Full`, or the primary key plus the `set`
172 + /// columns (deduped, PK first) for `PartialUpdate`.
173 + pub(crate) fn emitted_columns(&self) -> Vec<&'static str> {
174 + match &self.mode {
175 + SyncMode::Full => self.columns.to_vec(),
176 + SyncMode::PartialUpdate { set } => {
177 + let mut cols: Vec<&'static str> = self.pk.to_vec();
178 + for c in *set {
179 + if !cols.contains(c) {
180 + cols.push(c);
181 + }
182 + }
183 + cols
184 + }
185 + }
186 + }
187 + }
188 +
189 + /// An ordered set of syncable tables plus the conflict strategy.
190 + ///
191 + /// Declaration order is the foreign-key order: parents first. The engine upserts
192 + /// in this order and deletes in the exact reverse, so there is no separate,
193 + /// hand-maintained delete order to keep in sync.
194 + #[derive(Debug, Clone)]
195 + pub struct SyncSchema {
196 + pub(crate) tables: Vec<SyncTable>,
197 + pub(crate) conflict: ConflictStrategy,
198 + }
199 +
200 + impl SyncSchema {
201 + /// A schema from tables in foreign-key (parents-first) order. Defaults to
202 + /// [`ConflictStrategy::HybridLogicalClock`].
203 + pub fn new(tables: Vec<SyncTable>) -> Self {
204 + Self {
205 + tables,
206 + conflict: ConflictStrategy::HybridLogicalClock,
207 + }
208 + }
209 +
210 + /// Override the conflict strategy.
211 + pub fn conflict_strategy(mut self, conflict: ConflictStrategy) -> Self {
212 + self.conflict = conflict;
213 + self
214 + }
215 +
216 + /// The conflict strategy.
217 + pub fn conflict(&self) -> ConflictStrategy {
218 + self.conflict
219 + }
220 +
221 + /// The tables, in declared (parents-first) order.
222 + pub fn tables(&self) -> &[SyncTable] {
223 + &self.tables
224 + }
225 +
226 + /// Whether any table hashes its wire row-id (so the engine must provision a
227 + /// `row_id_salt` and register `hash_row_id`).
228 + pub(crate) fn any_hashed(&self) -> bool {
229 + self.tables
230 + .iter()
231 + .any(|t| t.row_id == RowIdScheme::Hashed)
232 + }
233 + }
@@ -0,0 +1,582 @@
1 + //! Push/pull loops and changelog lifecycle.
2 + //!
3 + //! This ties B2–B4 together against a transport: the drain-loop push (stamp →
4 + //! batch → send → mark-pushed + record committed), the paginated pull (fetch →
5 + //! resolve → apply → record committed → persist cursor), plus the initial
6 + //! snapshot, changelog cleanup, and retention cap.
7 + //!
8 + //! The engine's SQLite connection is `!Send`, so every database step runs on a
9 + //! blocking thread (`spawn_blocking`) with a fresh connection from the
10 + //! [`DbSource`](super::db::DbSource); the transport calls are the only `.await`
11 + //! points, and nothing `!Send` is held across them. The transport is abstracted
12 + //! behind [`SyncTransport`] so the loops test end-to-end against an in-memory
13 + //! fake, and `SyncKitClient` satisfies it in production.
14 +
15 + use std::collections::HashSet;
16 + use std::future::Future;
17 +
18 + use chrono::Utc;
19 + use rusqlite::Connection;
20 +
21 + use super::apply::{apply_remote_changes, ApplyOutcome};
22 + use super::db::{device_id, get_sync_state_or, set_device_id, set_sync_state, DbSource};
23 + use super::hlc::{record_committed, resolve_pull, set_committed, stamp_pending};
24 + use super::migrate::{json_object, row_id_expr};
25 + use super::schema::{SyncMode, SyncSchema};
26 + use crate::client::SyncKitClient;
27 + use crate::error::{Result, SyncKitError};
28 + use crate::ids::DeviceId;
29 + use crate::types::{ChangeEntry, ChangeOp, Device, Hlc, PulledChange};
30 +
31 + /// Maximum changes sent in one push batch.
32 + pub const PUSH_BATCH_LIMIT: usize = 500;
33 +
34 + /// The two operations the sync loops need from a server.
35 + ///
36 + /// A seam for testing (an in-memory fake) and the reference point a future
37 + /// non-Rust SDK mirrors. [`SyncKitClient`] implements it by forwarding to its
38 + /// inherent methods.
39 + pub trait SyncTransport: Sync {
40 + /// Register (or look up) this device with the server.
41 + fn register_device(
42 + &self,
43 + device_name: &str,
44 + platform: &str,
45 + ) -> impl Future<Output = Result<Device>> + Send;
46 +
47 + /// Push encrypted changes; returns the server's new cursor.
48 + fn push(
49 + &self,
50 + device_id: DeviceId,
51 + changes: Vec<ChangeEntry>,
52 + ) -> impl Future<Output = Result<i64>> + Send;
53 +
54 + /// Pull decrypted changes since `cursor`; returns `(changes, new_cursor,
55 + /// has_more)` with each change's originating device and sequence.
56 + fn pull_rich(
57 + &self,
58 + device_id: DeviceId,
59 + cursor: i64,
60 + ) -> impl Future<Output = Result<(Vec<PulledChange>, i64, bool)>> + Send;
61 + }
62 +
63 + impl SyncTransport for SyncKitClient {
64 + fn register_device(
65 + &self,
66 + device_name: &str,
67 + platform: &str,
68 + ) -> impl Future<Output = Result<Device>> + Send {
69 + SyncKitClient::register_device(self, device_name, platform)
70 + }
71 +
72 + fn push(
73 + &self,
74 + device_id: DeviceId,
75 + changes: Vec<ChangeEntry>,
76 + ) -> impl Future<Output = Result<i64>> + Send {
77 + // Inherent method (preferred over the trait method of the same name).
78 + SyncKitClient::push(self, device_id, changes)
79 + }
80 +
81 + fn pull_rich(
82 + &self,
83 + device_id: DeviceId,
84 + cursor: i64,
85 + ) -> impl Future<Output = Result<(Vec<PulledChange>, i64, bool)>> + Send {
86 + SyncKitClient::pull_rich(self, device_id, cursor)
87 + }
88 + }
89 +
90 + /// Ensure this install has a registered device, returning its id (which doubles
91 + /// as the HLC node). Fast path: a stored `device_id` returns without a network
92 + /// call. Otherwise register with the server and persist the assigned id.
93 + pub async fn ensure_device_registered<T: SyncTransport>(
94 + db: &DbSource,
95 + client: &T,
96 + device_name: &str,
97 + platform: &str,
98 + ) -> Result<DeviceId> {
99 + let db_read = db.clone();
100 + let existing = tokio::task::spawn_blocking(move || device_id(&db_read.open()?))
101 + .await
102 + .map_err(join_err)??;
103 + if let Some(id) = existing {
104 + return Ok(id);
105 + }
106 +
107 + let device = client.register_device(device_name, platform).await?;
108 + let id = device.id;
109 +
110 + let db_write = db.clone();
111 + tokio::task::spawn_blocking(move || set_device_id(&db_write.open()?, id))
112 + .await
113 + .map_err(join_err)??;
114 + Ok(id)
115 + }
116 +
117 + /// Outcome of a pull pass.
118 + #[derive(Debug, Default, PartialEq, Eq)]
119 + pub struct PullOutcome {
120 + pub applied: u64,
121 + pub changed_tables: HashSet<String>,
122 + }
123 +
124 + fn join_err(e: tokio::task::JoinError) -> SyncKitError {
125 + SyncKitError::Internal(format!("blocking task failed: {e}"))
126 + }
127 +
128 + // -- push --
129 +
130 + struct PushBatch {
131 + wire: Vec<ChangeEntry>,
132 + wire_ids: Vec<i64>,
133 + /// Rows marked pushed without sending (corrupt/unknown-op) so they can't wedge.
134 + skip_ids: Vec<i64>,
135 + total_read: usize,
136 + }
137 +
138 + /// Drain all unpushed local changes to the server, batch by batch.
139 + ///
140 + /// Each batch is stamped and read on a blocking thread, sent, then marked pushed
141 + /// with its HLC recorded as the committed clock (so a peer's later stale re-pull
142 + /// of this row is gated out). `device_id` doubles as the HLC node.
143 + pub async fn push_changes<T: SyncTransport>(
144 + db: &DbSource,
145 + client: &T,
146 + device_id: DeviceId,
147 + ) -> Result<u64> {
148 + let mut pushed_total = 0u64;
149 + loop {
150 + let db_read = db.clone();
151 + let batch = tokio::task::spawn_blocking(move || read_push_batch(&db_read.open()?, device_id))
152 + .await
153 + .map_err(join_err)??;
154 +
155 + if batch.wire.is_empty() && batch.skip_ids.is_empty() {
156 + break;
157 + }
158 +
159 + if !batch.wire.is_empty() {
160 + client.push(device_id, batch.wire.clone()).await?;
161 + }
162 +
163 + let db_write = db.clone();
164 + let PushBatch { wire, wire_ids, skip_ids, total_read } = batch;
165 + let sent = wire.len() as u64;
166 + tokio::task::spawn_blocking(move || {
167 + mark_pushed_committed(&mut db_write.open()?, &wire_ids, &skip_ids, &wire)
168 + })
169 + .await
170 + .map_err(join_err)??;
171 +
172 + pushed_total += sent;
173 + if total_read < PUSH_BATCH_LIMIT {
174 + break;
175 + }
176 + }
177 + Ok(pushed_total)
178 + }
179 +
180 + fn read_push_batch(conn: &Connection, device_id: DeviceId) -> Result<PushBatch> {
181 + stamp_pending(conn, device_id, Utc::now().timestamp_millis())?;
182 + let mut stmt = conn.prepare(
183 + "SELECT id, table_name, op, row_id, data, hlc_wall, hlc_counter \
184 + FROM sync_changelog WHERE pushed = 0 ORDER BY id LIMIT ?1",
185 + )?;
186 + let rows = stmt.query_map([PUSH_BATCH_LIMIT as i64], |r| {
187 + Ok((
188 + r.get::<_, i64>(0)?,
189 + r.get::<_, String>(1)?,
190 + r.get::<_, String>(2)?,
191 + r.get::<_, String>(3)?,
192 + r.get::<_, Option<String>>(4)?,
193 + r.get::<_, i64>(5)?,
194 + r.get::<_, i64>(6)?,
195 + ))
196 + })?;
197 +
198 + let mut batch = PushBatch { wire: Vec::new(), wire_ids: Vec::new(), skip_ids: Vec::new(), total_read: 0 };
199 + for row in rows {
200 + batch.total_read += 1;
201 + let (id, table, op, row_id, data, wall_ms, counter) = row?;
202 + // Corrupt/unknown rows are marked pushed (not sent) so they can't wedge
203 + // the drain by being re-read forever.
204 + let Some(op) = ChangeOp::from_str_opt(&op) else {
205 + batch.skip_ids.push(id);
206 + continue;
207 + };
208 + let data = match data.as_deref().map(serde_json::from_str).transpose() {
209 + Ok(v) => v,
210 + Err(_) => {
211 + batch.skip_ids.push(id);
212 + continue;
213 + }
214 + };
215 + batch.wire.push(ChangeEntry {
216 + table,
217 + op,
218 + row_id,
219 + timestamp: Utc::now(),
220 + hlc: Hlc { wall_ms, counter: counter as u32, node: device_id },
221 + data,
222 + extra: Default::default(),
223 + });
224 + batch.wire_ids.push(id);
225 + }
226 + Ok(batch)
227 + }
228 +
229 + fn mark_pushed_committed(
230 + conn: &mut Connection,
231 + wire_ids: &[i64],
232 + skip_ids: &[i64],
233 + wire: &[ChangeEntry],
234 + ) -> Result<()> {
235 + let tx = conn.transaction()?;
236 + for id in wire_ids.iter().chain(skip_ids) {
237 + tx.execute("UPDATE sync_changelog SET pushed = 1 WHERE id = ?1", [id])?;
238 + }
239 + // Push-side committed-ledger update: our own edit's HLC becomes the row's
240 + // committed clock, so a peer's later stale re-pull of it is gated out.
241 + for e in wire {
242 + set_committed(&tx, &e.table, &e.row_id, &e.hlc)?;
243 + }
244 + tx.commit()?;
245 + Ok(())
246 + }
247 +
248 + // -- pull --
249 +
250 + /// Pull, resolve, and apply all remote changes, page by page.
251 + ///
252 + /// Follows the SDK contract: the cursor is advanced only *after* the batch is
253 + /// applied and committed, in the same blocking step, so a crash re-pulls a batch
254 + /// the idempotent apply/gate absorbs.
255 + pub async fn pull_changes<T: SyncTransport>(
256 + db: &DbSource,
257 + client: &T,
258 + schema: &SyncSchema,
259 + device_id: DeviceId,
260 + ) -> Result<PullOutcome> {
261 + let mut out = PullOutcome::default();
262 + loop {
263 + let db_cursor = db.clone();
264 + let cursor = tokio::task::spawn_blocking(move || -> Result<i64> {
265 + Ok(get_sync_state_or(&db_cursor.open()?, "pull_cursor", "0")?
266 + .parse()
267 + .unwrap_or(0))
268 + })
269 + .await
270 + .map_err(join_err)??;
271 +
272 + let (pulled, new_cursor, has_more) = client.pull_rich(device_id, cursor).await?;
273 +
274 + if pulled.is_empty() {
275 + let db_c = db.clone();
276 + tokio::task::spawn_blocking(move || {
277 + set_sync_state(&db_c.open()?, "pull_cursor", &new_cursor.to_string())
278 + })
279 + .await
280 + .map_err(join_err)??;
281 + break;
282 + }
283 +
284 + let db_apply = db.clone();
285 + let schema = schema.clone();
286 + let outcome = tokio::task::spawn_blocking(move || {
287 + apply_pull(&mut db_apply.open()?, &schema, device_id, pulled, new_cursor)
288 + })
289 + .await
290 + .map_err(join_err)??;
291 +
292 + out.applied += outcome.applied as u64;
293 + out.changed_tables.extend(outcome.changed_tables);
294 + if !has_more {
295 + break;
296 + }
297 + }
298 + Ok(out)
299 + }
300 +
301 + fn apply_pull(
302 + conn: &mut Connection,
303 + schema: &SyncSchema,
304 + device_id: DeviceId,
305 + pulled: Vec<PulledChange>,
306 + new_cursor: i64,
307 + ) -> Result<ApplyOutcome> {
308 + let resolved = resolve_pull(conn, schema, device_id, pulled, Utc::now())?;
309 + let outcome = apply_remote_changes(conn, schema, &resolved)?;
310 + record_committed(conn, &resolved)?;
311 + set_sync_state(conn, "pull_cursor", &new_cursor.to_string())?;
312 + Ok(outcome)
313 + }
314 +
315 + // -- lifecycle (synchronous) --
316 +
317 + /// One-time backfill: snapshot every existing row into the changelog so a fresh
318 + /// device's pre-existing data pushes on the first sync. Idempotent — rows already
319 + /// represented in the changelog are skipped. Reuses the trigger's row-id and
320 + /// payload expressions, so snapshot entries are byte-identical to trigger entries.
321 + pub fn create_initial_snapshot(conn: &Connection, schema: &SyncSchema) -> Result<u64> {
322 + let mut total = 0u64;
323 + for table in &schema.tables {
324 + let name = table.name;
325 + let cols = table.emitted_columns();
326 + let row_id = row_id_expr(table, name);
327 + let data = json_object(name, &cols);
328 + // PartialUpdate tables reflect state changes; snapshot them as UPDATEs so
329 + // a peer applies them through the same partial path.
330 + let op = if matches!(table.mode, SyncMode::PartialUpdate { .. }) {
331 + "UPDATE"
332 + } else {
333 + "INSERT"
334 + };
335 + let exclude = table
336 + .exclude_where
337 + .map(|p| format!(" AND ({})", p.replace("{row}", name)))
338 + .unwrap_or_default();
339 +
340 + let sql = format!(
341 + "INSERT INTO sync_changelog (table_name, op, row_id, data) \
342 + SELECT '{name}', '{op}', {row_id}, {data} FROM {name} \
343 + WHERE NOT EXISTS ( \
344 + SELECT 1 FROM sync_changelog sc \
345 + WHERE sc.table_name = '{name}' AND sc.row_id = {row_id} \
346 + ){exclude}"
347 + );
348 + total += conn.execute(&sql, [])? as u64;
349 + }
350 + set_sync_state(conn, "initial_snapshot_done", "1")?;
351 + Ok(total)
352 + }
353 +
354 + /// Prune pushed changelog entries older than seven days.
355 + pub fn cleanup_changelog(conn: &Connection) -> Result<u64> {
356 + let n = conn.execute(
357 + "DELETE FROM sync_changelog WHERE pushed = 1 AND timestamp < datetime('now', '-7 days')",
358 + [],
359 + )?;
360 + Ok(n as u64)
361 + }
362 +
363 + /// Cap changelog size: drop the oldest *pushed* entries beyond the most recent
364 + /// `cap`. Unpushed entries are never dropped (that would lose un-synced data), so
365 + /// a backlog larger than `cap` is preserved until it syncs.
366 + pub fn enforce_changelog_retention(conn: &Connection, cap: i64) -> Result<u64> {
367 + let n = conn.execute(
368 + "DELETE FROM sync_changelog WHERE pushed = 1 AND id NOT IN ( \
369 + SELECT id FROM sync_changelog ORDER BY id DESC LIMIT ?1 \
370 + )",
371 + [cap],
372 + )?;
373 + Ok(n as u64)
374 + }
375 +
376 + #[cfg(test)]
377 + mod tests {
378 + use super::super::schema::SyncTable;
379 + use super::*;
380 + use std::sync::{Arc, Mutex};
381 +
382 + /// A shared in-memory "server": an append-only log of (origin device, entry).
383 + #[derive(Clone, Default)]
384 + struct FakeServer {
385 + log: Arc<Mutex<Vec<(DeviceId, ChangeEntry)>>>,
386 + }
387 +
388 + impl SyncTransport for FakeServer {
389 + async fn register_device(&self, _name: &str, _platform: &str) -> Result<Device> {
390 + Ok(Device {
391 + id: DeviceId::new(uuid::Uuid::from_u128(0xDE)),
392 + app_id: crate::ids::AppId::nil(),
393 + user_id: crate::ids::UserId::nil(),
394 + device_name: "fake".into(),
395 + platform: "test".into(),
396 + last_seen_at: Utc::now(),
397 + created_at: Utc::now(),
398 + })
399 + }
400 +
401 + fn push(
402 + &self,
403 + device_id: DeviceId,
404 + changes: Vec<ChangeEntry>,
405 + ) -> impl Future<Output = Result<i64>> + Send {
406 + let log = self.log.clone();
407 + async move {
408 + let mut l = log.lock().unwrap();
409 + for c in changes {
410 + l.push((device_id, c));
411 + }
412 + Ok(l.len() as i64)
413 + }
414 + }
415 +
416 + fn pull_rich(
417 + &self,
418 + _device_id: DeviceId,
419 + cursor: i64,
420 + ) -> impl Future<Output = Result<(Vec<PulledChange>, i64, bool)>> + Send {
421 + let log = self.log.clone();
422 + async move {
423 + let l = log.lock().unwrap();
424 + let out: Vec<PulledChange> = l
425 + .iter()
426 + .enumerate()
427 + .filter(|(i, _)| (*i as i64 + 1) > cursor)
428 + .map(|(i, (dev, entry))| PulledChange {
429 + entry: entry.clone(),
430 + device_id: *dev,
431 + seq: i as i64 + 1,
432 + })
433 + .collect();
434 + Ok((out, l.len() as i64, false))
435 + }
436 + }
437 + }
438 +
439 + fn schema() -> SyncSchema {
440 + SyncSchema::new(vec![SyncTable::full("note", &["id", "name"])])
441 + }
442 +
443 + fn device(path: &std::path::Path, n: u128) -> (DbSource, DeviceId) {
444 + let db = DbSource::path(path);
445 + let conn = db.open().unwrap();
446 + conn.execute_batch("CREATE TABLE note (id TEXT PRIMARY KEY, name TEXT);").unwrap();
447 + conn.execute_batch(&schema().migration_sql()).unwrap();
448 + (db, DeviceId::new(uuid::Uuid::from_u128(n)))
449 + }
450 +
451 + fn edit(db: &DbSource, id: &str, name: &str) {
452 + let conn = db.open().unwrap();
453 + conn.execute(
454 + "INSERT INTO note (id, name) VALUES (?1, ?2) ON CONFLICT(id) DO UPDATE SET name = excluded.name",
455 + (id, name),
456 + )
457 + .unwrap();
458 + }
459 +
460 + fn stamp_at(db: &DbSource, node: DeviceId, now_ms: i64) {
461 + stamp_pending(&db.open().unwrap(), node, now_ms).unwrap();
462 + }
463 +
464 + fn note_name(db: &DbSource, id: &str) -> Option<String> {
465 + db.open()
466 + .unwrap()
467 + .query_row("SELECT name FROM note WHERE id = ?1", [id], |r| r.get(0))
468 + .ok()
469 + }
470 +
471 + #[tokio::test]
472 + async fn two_device_push_pull_converges_to_higher_hlc() {
473 + let dir = tempdir();
474 + let (da, na) = device(&dir.join("a.db"), 1);
475 + let (db_, nb) = device(&dir.join("b.db"), 2);
476 + let server = FakeServer::default();
477 +
478 + // A edits first (t=100), B edits the same row later (t=200) → B wins.
479 + edit(&da, "r", "from-A");
480 + stamp_at(&da, na, 100);
481 + edit(&db_, "r", "from-B");
482 + stamp_at(&db_, nb, 200);
483 +
484 + push_changes(&da, &server, na).await.unwrap();
485 + push_changes(&db_, &server, nb).await.unwrap();
486 +
487 + pull_changes(&da, &server, &schema(), na).await.unwrap();
488 + pull_changes(&db_, &server, &schema(), nb).await.unwrap();
489 +
490 + assert_eq!(note_name(&da, "r").as_deref(), Some("from-B"));
491 + assert_eq!(note_name(&db_, "r").as_deref(), Some("from-B"));
492 + }
493 +
494 + #[tokio::test]
495 + async fn push_marks_rows_and_advances_cursor() {
496 + let dir = tempdir();
497 + let (da, na) = device(&dir.join("a.db"), 1);
498 + let server = FakeServer::default();
499 + edit(&da, "r1", "x");
500 + edit(&da, "r2", "y");
Lines truncated