baukit_sync/lib.rs
1//! Revision allocation and hybrid logical clocks for incremental sync.
2//!
3//! A syncable row carries a `revision` drawn from a counter that is private to
4//! its owner. A client pulls by asking for everything above the revision it
5//! last saw, so the counter must be monotonic per owner and must move in the
6//! same transaction as the row write it stamps. [`next_revision`] does exactly
7//! that. The [`hlc`] module supplies a cross-runtime logical clock for ordering
8//! writes when physical clocks stall or move backward.
9//!
10//! # What this crate is not
11//!
12//! Baukit does not standardize a sync protocol. Wire payloads, conflict
13//! resolution, batching, and the pull endpoint stay product-owned, as
14//! `docs/platform/offline-readiness-contract.md` says. This crate owns two
15//! mechanisms: revision allocation and timestamp generation. Merge rules stay
16//! product-owned.
17//!
18//! # Schema
19//!
20//! Products copy [`POSTGRES_MIGRATION_SQL`] into their own ordered migrations;
21//! the crate never runs migrations at process startup. Products whose existing
22//! counter uses `user_id` can instead copy
23//! [`POSTGRES_RENAME_USER_ID_TO_OWNER_ID_SQL`]. The reference migration also
24//! documents the column convention every syncable table follows: `id`,
25//! `owner_id`, `updated_at`, `deleted_at`, `revision`, plus an `(owner_id,
26//! revision)` index.
27//!
28//! # Usage
29//!
30//! Call [`next_revision`] inside the transaction that writes the row, and stamp
31//! the returned value onto that row. If the transaction rolls back, the
32//! allocation rolls back with it and the revision is never handed out.
33//!
34//! ```no_run
35//! # async fn example(pool: &sqlx::PgPool, owner_id: uuid::Uuid) -> Result<(), sqlx::Error> {
36//! let mut transaction = pool.begin().await?;
37//! let revision = baukit_sync::next_revision(&mut transaction, owner_id).await?;
38//! sqlx::query("UPDATE product_records SET revision = $1 WHERE owner_id = $2")
39//! .bind(revision)
40//! .bind(owner_id)
41//! .execute(&mut *transaction)
42//! .await?;
43//! transaction.commit().await?;
44//! # Ok(())
45//! # }
46//! ```
47
48#![deny(missing_docs)]
49
50use sqlx::{Postgres, Transaction};
51use uuid::Uuid;
52
53pub mod hlc;
54
55/// Reference PostgreSQL schema and column convention for product migrations.
56///
57/// Copy this SQL into a product migration; do not execute it dynamically during
58/// application startup.
59pub const POSTGRES_MIGRATION_SQL: &str = include_str!("../migrations/0001_baukit_sync.sql");
60
61/// One-shot PostgreSQL migration from `sync_revisions.user_id` to `owner_id`.
62///
63/// Copy this SQL into a product migration only when its existing table has the
64/// old `user_id` shape and the foreign key has the conventional
65/// `sync_revisions_user_id_fkey` name. PostgreSQL keeps the foreign key and its
66/// delete action when the column is renamed. The SQL renames that constraint
67/// for clarity and adds the canonical non-negative revision check.
68///
69/// This migration is intentionally one-shot. It fails if `owner_id` already
70/// exists, which prevents a partially applicable migration from being hidden.
71pub const POSTGRES_RENAME_USER_ID_TO_OWNER_ID_SQL: &str =
72 include_str!("../postgres_rename_user_id_to_owner_id.sql");
73
74/// Allocates the owner's next revision inside the caller's transaction.
75///
76/// The `UPDATE ... RETURNING` takes a row lock for the duration of the
77/// transaction, so concurrent writers for one owner serialize and each sees a
78/// distinct, increasing value. Different owners touch different rows and do not
79/// block each other. A rollback discards the allocation.
80///
81/// The owner's counter row must exist; call [`ensure_owner`] once when the
82/// owner is created.
83///
84/// # Errors
85///
86/// Returns [`sqlx::Error::RowNotFound`] when the owner has no counter row, and
87/// any other database error unchanged.
88pub async fn next_revision(
89 transaction: &mut Transaction<'_, Postgres>,
90 owner_id: Uuid,
91) -> Result<i64, sqlx::Error> {
92 sqlx::query_scalar(
93 "UPDATE sync_revisions
94 SET last_revision = last_revision + 1
95 WHERE owner_id = $1
96 RETURNING last_revision",
97 )
98 .bind(owner_id)
99 .fetch_one(&mut **transaction)
100 .await
101}
102
103/// Creates the owner's revision counter if it does not exist yet.
104///
105/// Call this when the owner is created, in the same transaction. Calling it
106/// again is harmless and never resets an existing counter.
107///
108/// # Errors
109///
110/// Returns any database error unchanged.
111pub async fn ensure_owner(
112 transaction: &mut Transaction<'_, Postgres>,
113 owner_id: Uuid,
114) -> Result<(), sqlx::Error> {
115 sqlx::query(
116 "INSERT INTO sync_revisions (owner_id)
117 VALUES ($1)
118 ON CONFLICT (owner_id) DO NOTHING",
119 )
120 .bind(owner_id)
121 .execute(&mut **transaction)
122 .await
123 .map(|_| ())
124}
125
126/// Reads the owner's current revision without allocating a new one.
127///
128/// Use this to answer "what is the newest revision a pull could return". It
129/// takes no lock, so a concurrent writer may advance the counter immediately
130/// after the read.
131///
132/// # Errors
133///
134/// Returns any database error unchanged. An owner without a counter row reads
135/// as `None`.
136pub async fn current_revision(
137 transaction: &mut Transaction<'_, Postgres>,
138 owner_id: Uuid,
139) -> Result<Option<i64>, sqlx::Error> {
140 sqlx::query_scalar("SELECT last_revision FROM sync_revisions WHERE owner_id = $1")
141 .bind(owner_id)
142 .fetch_optional(&mut **transaction)
143 .await
144}
145
146/// Reads and locks the owner's current revision without allocating a new one.
147///
148/// The row lock lasts until the caller's transaction commits or rolls back.
149/// Use this before a read-dependent write that must not race another revision
150/// allocation. [`current_revision`] remains the non-locking read for pull
151/// boundaries and status queries.
152///
153/// # Errors
154///
155/// Returns any database error unchanged. An owner without a counter row reads
156/// as `None` and no row is locked.
157pub async fn current_revision_for_update(
158 transaction: &mut Transaction<'_, Postgres>,
159 owner_id: Uuid,
160) -> Result<Option<i64>, sqlx::Error> {
161 sqlx::query_scalar("SELECT last_revision FROM sync_revisions WHERE owner_id = $1 FOR UPDATE")
162 .bind(owner_id)
163 .fetch_optional(&mut **transaction)
164 .await
165}
166
167// Compiles the README's examples so they cannot drift from the API.
168#[doc = include_str!("../README.md")]
169#[cfg(doctest)]
170struct ReadmeDoctests;