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