baukit_sync/lib.rs
1//! Revision allocation, tombstone purge horizons, and hybrid logical clocks
2//! for incremental sync.
3//!
4//! A syncable row carries a `revision` drawn from a counter that is private to
5//! its owner. A client pulls by asking for everything above the revision it
6//! last saw, so the counter must be monotonic per owner and must move in the
7//! same transaction as the row write it stamps. [`next_revision`] does exactly
8//! that. The [`purge`] module deletes old tombstones and records the per-owner
9//! purge horizon, and [`horizon`] holds the pull-cursor rule that turns a
10//! cursor below the horizon into a full-resync signal. The [`hlc`] module
11//! supplies a cross-runtime logical clock for ordering writes when physical
12//! clocks stall or move backward.
13//!
14//! # Features
15//!
16//! Without features the crate contains only [`hlc`], [`horizon`], and the
17//! reference SQL constants, and depends on no database driver. The
18//! `sqlx-postgres` feature adds the PostgreSQL revision allocator and the
19//! [`purge`] module.
20//!
21//! # What this crate is not
22//!
23//! Baukit does not standardize a sync protocol. Wire payloads, conflict
24//! resolution, batching, and the pull endpoint stay product-owned, as
25//! `docs/platform/offline-readiness-contract.md` says. This crate owns
26//! revision allocation, tombstone purge horizons, and timestamp generation.
27//! Merge rules, retention periods, table lists, and scheduling stay
28//! product-owned.
29//!
30//! # Schema
31//!
32//! Products copy [`POSTGRES_MIGRATION_SQL`] and
33//! [`POSTGRES_PURGE_HORIZONS_MIGRATION_SQL`] into their own ordered
34//! migrations; the crate never runs migrations at process startup. Products
35//! whose existing counter uses `user_id` can instead copy
36//! [`POSTGRES_RENAME_USER_ID_TO_OWNER_ID_SQL`]. The reference migration also
37//! documents the column convention every syncable table follows: `id`,
38//! `owner_id`, `updated_at`, `deleted_at`, `revision`, plus an `(owner_id,
39//! revision)` index.
40//!
41//! Key syncable tables by `PRIMARY KEY (owner_id, id)`, because clients pick
42//! `id` and two owners can send the same one. With a global `id` key, keep the
43//! owner in the `WHERE` of every write and never upsert with a bare
44//! `ON CONFLICT (id)`:
45//!
46//! ```sql
47//! INSERT INTO product_records (owner_id, id, deleted_at, revision)
48//! VALUES ($1, $2, $3, $4)
49//! ON CONFLICT (owner_id, id) DO UPDATE
50//! SET deleted_at = EXCLUDED.deleted_at, revision = EXCLUDED.revision
51//! WHERE product_records.owner_id = EXCLUDED.owner_id;
52//! ```
53//!
54//! # Usage
55//!
56//! Call [`next_revision`] inside the transaction that writes the row, and stamp
57//! the returned value onto that row. If the transaction rolls back, the
58//! allocation rolls back with it and the revision is never handed out.
59//!
60//! ```no_run
61//! # #[cfg(feature = "sqlx-postgres")]
62//! # async fn example(pool: &sqlx::PgPool, owner_id: uuid::Uuid) -> Result<(), sqlx::Error> {
63//! let mut transaction = pool.begin().await?;
64//! let revision = baukit_sync::next_revision(&mut transaction, owner_id).await?;
65//! sqlx::query("UPDATE product_records SET revision = $1 WHERE owner_id = $2")
66//! .bind(revision)
67//! .bind(owner_id)
68//! .execute(&mut *transaction)
69//! .await?;
70//! transaction.commit().await?;
71//! # Ok(())
72//! # }
73//! ```
74
75#![deny(missing_docs)]
76
77pub mod hlc;
78pub mod horizon;
79#[cfg(feature = "sqlx-postgres")]
80pub mod purge;
81#[cfg(feature = "sqlx-postgres")]
82mod revision;
83
84#[cfg(feature = "sqlx-postgres")]
85pub use revision::{current_revision, current_revision_for_update, ensure_owner, next_revision};
86
87/// Reference PostgreSQL schema and column convention for product migrations.
88///
89/// Copy this SQL into a product migration; do not execute it dynamically during
90/// application startup.
91pub const POSTGRES_MIGRATION_SQL: &str = include_str!("../migrations/0001_baukit_sync.sql");
92
93/// Reference PostgreSQL schema for per-owner tombstone purge horizons.
94///
95/// Copy this SQL into a product migration after [`POSTGRES_MIGRATION_SQL`].
96/// The [`purge`] module reads and writes the `sync_purge_horizons` table it
97/// creates. The table name is fixed, like `sync_revisions`.
98pub const POSTGRES_PURGE_HORIZONS_MIGRATION_SQL: &str =
99 include_str!("../migrations/0002_baukit_sync_purge_horizons.sql");
100
101/// One-shot PostgreSQL migration from `sync_revisions.user_id` to `owner_id`.
102///
103/// Copy this SQL into a product migration only when its existing table has the
104/// old `user_id` shape and the foreign key has the conventional
105/// `sync_revisions_user_id_fkey` name. PostgreSQL keeps the foreign key and its
106/// delete action when the column is renamed. The SQL renames that constraint
107/// for clarity and adds the canonical non-negative revision check.
108///
109/// This migration is intentionally one-shot. It fails if `owner_id` already
110/// exists, which prevents a partially applicable migration from being hidden.
111pub const POSTGRES_RENAME_USER_ID_TO_OWNER_ID_SQL: &str =
112 include_str!("../postgres_rename_user_id_to_owner_id.sql");
113
114// Compiles the README's examples so they cannot drift from the API.
115#[doc = include_str!("../README.md")]
116#[cfg(all(doctest, feature = "sqlx-postgres"))]
117struct ReadmeDoctests;