#![cfg_attr(
not(test),
deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::todo,
clippy::unimplemented,
clippy::indexing_slicing,
)
)]
use std::collections::{BTreeMap, HashMap};
use std::sync::{Arc, Mutex};
use axum::Json;
use axum::extract::{FromRequestParts, OptionalFromRequestParts, Query};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::routing::{get, post};
use chrono::{DateTime, Utc};
use diesel::prelude::*;
use diesel::sql_query;
use diesel::sql_types::{BigInt, Bool, Jsonb, Nullable, Text, Timestamptz};
use super::SyncError;
use super::protocol::{
Change, ChangeOutcome, MAX_PULL_LIMIT, MAX_PUSH_CHANGES, Op, PullQuery, PullResponse,
PushRequest, PushResponse, RemoteRow, Version,
};
use super::resolver::{ConflictResolver, Resolution};
fn backend_err(err: impl std::fmt::Display) -> SyncError {
SyncError::Backend(err.to_string())
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct SyncScope(String);
impl SyncScope {
pub const GLOBAL: &'static str = "global";
#[must_use]
pub fn new(scope: impl Into<String>) -> Self {
Self(scope.into())
}
#[must_use]
pub fn global() -> Self {
Self(Self::GLOBAL.to_owned())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for SyncScope {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl<S> FromRequestParts<S> for SyncScope
where
S: Send + Sync,
{
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(
parts: &mut axum::http::request::Parts,
_state: &S,
) -> Result<Self, Self::Rejection> {
parts
.extensions
.get::<Self>()
.cloned()
.ok_or(SCOPE_MISSING_REJECTION)
}
}
impl<S> OptionalFromRequestParts<S> for SyncScope
where
S: Send + Sync,
{
type Rejection = std::convert::Infallible;
async fn from_request_parts(
parts: &mut axum::http::request::Parts,
_state: &S,
) -> Result<Option<Self>, Self::Rejection> {
Ok(parts.extensions.get::<Self>().cloned())
}
}
const SCOPE_MISSING_REJECTION: (StatusCode, &str) = (
StatusCode::INTERNAL_SERVER_ERROR,
"sync scope missing: this deployment requires authentication middleware \
to insert a SyncScope request extension (see \
autumn_web::sync::server::scoped_router)",
);
fn validate_push(request: &PushRequest) -> Result<(), SyncError> {
let mut seen = std::collections::HashSet::with_capacity(request.changes.len());
for change in &request.changes {
if change.op == Op::Upsert && change.payload.is_none() {
return Err(SyncError::Protocol(format!(
"upsert change {} ({}/{}) has no payload — upserts must carry \
a JSON payload (only deletes may omit it); nothing from this \
batch was applied",
change.change_id, change.collection, change.pk
)));
}
if !seen.insert(change.change_id.as_str()) {
return Err(SyncError::Protocol(format!(
"change_id {} appears more than once in this push batch — \
each change must carry a unique id (a repeat would replay \
the first occurrence's outcome instead of applying, while \
the pushing client records the ack as its own); nothing \
from this batch was applied",
change.change_id
)));
}
}
Ok(())
}
fn row_from_change(change: &Change, device_id: &str, version: Version) -> RemoteRow {
RemoteRow {
collection: change.collection.clone(),
pk: change.pk.clone(),
payload: if change.op == Op::Delete {
None
} else {
change.payload.clone()
},
version,
deleted: change.op == Op::Delete,
updated_at: change.updated_at,
device_id: device_id.to_owned(),
}
}
fn resolved_row(
resolution: Resolution,
change: &Change,
device_id: &str,
server: &RemoteRow,
version: Version,
) -> RemoteRow {
match resolution {
Resolution::KeepServer => RemoteRow {
version,
..server.clone()
},
Resolution::TakeClient => row_from_change(change, device_id, version),
Resolution::Merge(payload) => RemoteRow {
collection: change.collection.clone(),
pk: change.pk.clone(),
payload: Some(payload),
version,
deleted: false,
updated_at: change.updated_at.max(server.updated_at),
device_id: device_id.to_owned(),
},
}
}
fn gc_tombstone_row(change: &Change, version: Version) -> RemoteRow {
RemoteRow {
collection: change.collection.clone(),
pk: change.pk.clone(),
payload: None,
version,
deleted: true,
updated_at: Utc::now(),
device_id: String::new(),
}
}
#[derive(Debug, Clone)]
struct ServerRow {
row: RemoteRow,
created_version: Version,
}
fn apply_change_row(
change: &Change,
device_id: &str,
current: Option<&ServerRow>,
horizon: Version,
resolver: &dyn ConflictResolver,
version: Version,
) -> (ServerRow, bool) {
let previous_incarnation =
change.base_version > 0 && current.is_some_and(|c| change.base_version < c.created_version);
match current {
Some(cur) if previous_incarnation && !cur.row.deleted => (
ServerRow {
row: resolved_row(Resolution::KeepServer, change, device_id, &cur.row, version),
created_version: cur.created_version,
},
false,
),
Some(cur) if previous_incarnation => (
ServerRow {
row: gc_tombstone_row(change, version),
created_version: cur.created_version,
},
false,
),
Some(cur) if cur.row.version != change.base_version => {
let resolution = resolver.resolve(device_id, change, &cur.row);
let row = resolved_row(resolution, change, device_id, &cur.row, version);
let created_version = if cur.row.deleted && !row.deleted {
version
} else {
cur.created_version
};
(
ServerRow {
row,
created_version,
},
false,
)
}
None if change.base_version > 0 && change.base_version < horizon => (
ServerRow {
row: gc_tombstone_row(change, version),
created_version: version,
},
false,
),
_ => {
let row = row_from_change(change, device_id, version);
let created_version = match current {
Some(cur) if !cur.row.deleted => cur.created_version,
Some(cur) if row.deleted => cur.created_version,
_ => version,
};
(
ServerRow {
row,
created_version,
},
true,
)
}
}
}
pub trait SyncBackend: Send + Sync + 'static {
fn apply_push(
&self,
scope: &str,
request: &PushRequest,
resolver: &dyn ConflictResolver,
) -> Result<PushResponse, SyncError>;
fn pull_since(
&self,
scope: &str,
cursor: Version,
limit: i64,
session_start: Version,
) -> Result<PullResponse, SyncError>;
fn gc_tombstones(&self, up_to: Version) -> Result<u64, SyncError>;
fn gc_applied(&self, older_than: DateTime<Utc>) -> Result<u64, SyncError>;
fn tombstone_horizon(&self, scope: &str) -> Result<Version, SyncError>;
fn latest_version(&self) -> Result<Version, SyncError>;
}
#[derive(Debug, Clone)]
struct AppliedRecord {
version: Version,
applied_at: DateTime<Utc>,
resolved_row: Option<RemoteRow>,
}
#[derive(Debug, Default)]
struct MemoryState {
rows: BTreeMap<(String, String, String), ServerRow>,
applied: HashMap<(String, String, String), AppliedRecord>,
next_version: Version,
horizons: HashMap<String, Version>,
}
impl MemoryState {
const fn allocate_version(&mut self) -> Version {
self.next_version += 1;
self.next_version
}
}
#[derive(Debug, Default)]
pub struct MemorySyncBackend {
state: Mutex<MemoryState>,
}
impl MemorySyncBackend {
#[must_use]
pub fn new() -> Self {
Self::default()
}
fn lock(&self) -> Result<std::sync::MutexGuard<'_, MemoryState>, SyncError> {
self.state
.lock()
.map_err(|_| SyncError::Backend("memory backend mutex poisoned".into()))
}
}
impl SyncBackend for MemorySyncBackend {
fn apply_push(
&self,
scope: &str,
request: &PushRequest,
resolver: &dyn ConflictResolver,
) -> Result<PushResponse, SyncError> {
validate_push(request)?;
let mut state = self.lock()?;
let horizon = state.horizons.get(scope).copied().unwrap_or(0);
let mut outcomes = Vec::with_capacity(request.changes.len());
for change in &request.changes {
let dedup_key = (
scope.to_owned(),
request.device_id.clone(),
change.change_id.clone(),
);
if let Some(record) = state.applied.get(&dedup_key) {
outcomes.push(record.resolved_row.as_ref().map_or(
ChangeOutcome::AlreadyApplied {
version: record.version,
},
|row| ChangeOutcome::Resolved { row: row.clone() },
));
continue;
}
let row_key = (
scope.to_owned(),
change.collection.clone(),
change.pk.clone(),
);
let current = state.rows.get(&row_key).cloned();
let version = state.allocate_version();
let (stored, clean) = apply_change_row(
change,
&request.device_id,
current.as_ref(),
horizon,
resolver,
version,
);
let outcome = if clean {
state.rows.insert(row_key, stored);
ChangeOutcome::Applied { version }
} else {
let row = stored.row.clone();
state.rows.insert(row_key, stored);
ChangeOutcome::Resolved { row }
};
let resolved_row = match &outcome {
ChangeOutcome::Resolved { row } => Some(row.clone()),
_ => None,
};
state.applied.insert(
dedup_key,
AppliedRecord {
version,
applied_at: Utc::now(),
resolved_row,
},
);
outcomes.push(outcome);
}
drop(state);
Ok(PushResponse { outcomes })
}
fn pull_since(
&self,
scope: &str,
cursor: Version,
limit: i64,
session_start: Version,
) -> Result<PullResponse, SyncError> {
let state = self.lock()?;
let horizon = state.horizons.get(scope).copied().unwrap_or(0);
if session_start > 0 && session_start < horizon {
return Ok(PullResponse::FullResyncRequired {
tombstone_horizon: horizon,
});
}
let mut rows: Vec<RemoteRow> = state
.rows
.iter()
.filter(|((row_scope, _, _), stored)| row_scope == scope && stored.row.version > cursor)
.map(|(_, stored)| stored.row.clone())
.collect();
drop(state);
rows.sort_by_key(|row| row.version);
rows.truncate(usize::try_from(limit.max(0)).unwrap_or(usize::MAX));
let next_cursor = rows.last().map_or(cursor, |row| row.version);
Ok(PullResponse::Ok {
rows,
next_cursor,
tombstone_horizon: horizon,
})
}
fn gc_tombstones(&self, up_to: Version) -> Result<u64, SyncError> {
let mut state = self.lock()?;
let mut per_scope: HashMap<String, Version> = HashMap::new();
let mut removed = 0u64;
state.rows.retain(|(row_scope, _, _), stored| {
if stored.row.deleted && stored.row.version <= up_to {
let max = per_scope.entry(row_scope.clone()).or_insert(0);
*max = (*max).max(stored.row.version);
removed += 1;
false
} else {
true
}
});
for (gc_scope, dropped_max) in per_scope {
let horizon = state.horizons.entry(gc_scope).or_insert(0);
*horizon = (*horizon).max(dropped_max);
}
drop(state);
Ok(removed)
}
fn gc_applied(&self, older_than: DateTime<Utc>) -> Result<u64, SyncError> {
let mut state = self.lock()?;
let before = state.applied.len();
state
.applied
.retain(|_, record| record.applied_at >= older_than);
let removed = before - state.applied.len();
drop(state);
Ok(removed as u64)
}
fn tombstone_horizon(&self, scope: &str) -> Result<Version, SyncError> {
Ok(self.lock()?.horizons.get(scope).copied().unwrap_or(0))
}
fn latest_version(&self) -> Result<Version, SyncError> {
Ok(self.lock()?.next_version)
}
}
const PG_SCHEMA_DDL: &str = "
CREATE SEQUENCE IF NOT EXISTS autumn_sync_version_seq;
CREATE TABLE IF NOT EXISTS autumn_sync_rows (
-- Tenant/principal partition key, derived server-side from the
-- authenticated request (see SyncScope) — never client-supplied.
scope TEXT NOT NULL,
collection TEXT NOT NULL,
pk TEXT NOT NULL,
payload JSONB,
version BIGINT NOT NULL,
deleted BOOLEAN NOT NULL DEFAULT FALSE,
updated_at TIMESTAMPTZ NOT NULL,
device_id TEXT NOT NULL DEFAULT '',
-- The version this INCARNATION of the row began at: set on insert and
-- on every revival from a tombstone/absent state. A pushed base below
-- it referred to a previous (deleted + recreated) incarnation and is
-- settled server-winning without consulting the resolver.
created_version BIGINT NOT NULL,
PRIMARY KEY (scope, collection, pk)
);
CREATE INDEX IF NOT EXISTS autumn_sync_rows_scope_version_idx
ON autumn_sync_rows (scope, version);
CREATE TABLE IF NOT EXISTS autumn_sync_applied (
-- Scope prefix on the dedup key too: device_id/change_id are
-- client-supplied, so without it a client in another scope could
-- replay a foreign record's outcome (and read its resolved_row).
scope TEXT NOT NULL,
device_id TEXT NOT NULL,
change_id TEXT NOT NULL,
version BIGINT NOT NULL DEFAULT 0,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Snapshot of the resolved row when the original outcome was Resolved,
-- so retries replay the resolution instead of a clean-looking
-- AlreadyApplied ack (NULL for clean applies).
resolved_row JSONB,
PRIMARY KEY (scope, device_id, change_id)
);
CREATE TABLE IF NOT EXISTS autumn_sync_horizons (
-- Per-scope tombstone GC horizon: the highest tombstone version ever
-- physically dropped in that scope. Tracked PER SCOPE so one tenant's
-- GC neither forces other tenants' full resyncs nor routes their
-- ordinary conflicts into the pre-horizon server-winning arms.
scope TEXT PRIMARY KEY,
horizon BIGINT NOT NULL
);
";
const PG_PUSH_ADVISORY_LOCK_KEY: i64 = 0x4154_4D4E_5359_4E43;
#[derive(QueryableByName)]
struct PgRowRecord {
#[diesel(sql_type = Text)]
collection: String,
#[diesel(sql_type = Text)]
pk: String,
#[diesel(sql_type = Nullable<Jsonb>)]
payload: Option<serde_json::Value>,
#[diesel(sql_type = BigInt)]
version: i64,
#[diesel(sql_type = Bool)]
deleted: bool,
#[diesel(sql_type = Timestamptz)]
updated_at: DateTime<Utc>,
#[diesel(sql_type = Text)]
device_id: String,
#[diesel(sql_type = BigInt)]
created_version: i64,
}
impl PgRowRecord {
fn into_server_row(self) -> ServerRow {
ServerRow {
row: RemoteRow {
collection: self.collection,
pk: self.pk,
payload: self.payload,
version: self.version,
deleted: self.deleted,
updated_at: self.updated_at,
device_id: self.device_id,
},
created_version: self.created_version,
}
}
}
#[derive(QueryableByName)]
struct PgVersionRecord {
#[diesel(sql_type = BigInt)]
version: i64,
}
#[derive(QueryableByName)]
struct PgAppliedRecord {
#[diesel(sql_type = BigInt)]
version: i64,
#[diesel(sql_type = Nullable<Jsonb>)]
resolved_row: Option<serde_json::Value>,
}
#[derive(QueryableByName)]
struct PgDroppedRecord {
#[diesel(sql_type = Text)]
scope: String,
#[diesel(sql_type = BigInt)]
version: i64,
}
#[derive(QueryableByName)]
struct PgSequenceRecord {
#[diesel(sql_type = BigInt)]
last_value: i64,
#[diesel(sql_type = Bool)]
is_called: bool,
}
fn pg_upsert_row<C>(
conn: &mut C,
scope: &str,
stored: &ServerRow,
) -> Result<(), diesel::result::Error>
where
C: diesel::connection::LoadConnection<Backend = diesel::pg::Pg>,
{
let row = &stored.row;
sql_query(
"INSERT INTO autumn_sync_rows \
(scope, collection, pk, payload, version, deleted, updated_at, device_id, \
created_version) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) \
ON CONFLICT (scope, collection, pk) DO UPDATE SET \
payload = excluded.payload, version = excluded.version, \
deleted = excluded.deleted, updated_at = excluded.updated_at, \
device_id = excluded.device_id, \
created_version = excluded.created_version",
)
.bind::<Text, _>(scope)
.bind::<Text, _>(&row.collection)
.bind::<Text, _>(&row.pk)
.bind::<Nullable<Jsonb>, _>(&row.payload)
.bind::<BigInt, _>(row.version)
.bind::<Bool, _>(row.deleted)
.bind::<Timestamptz, _>(row.updated_at)
.bind::<Text, _>(&row.device_id)
.bind::<BigInt, _>(stored.created_version)
.execute(conn)
.map(|_| ())
}
fn pg_next_version<C>(conn: &mut C) -> Result<Version, diesel::result::Error>
where
C: diesel::connection::LoadConnection<Backend = diesel::pg::Pg>,
{
sql_query("SELECT nextval('autumn_sync_version_seq') AS version")
.get_result::<PgVersionRecord>(conn)
.map(|record| record.version)
}
fn pg_horizon<C>(conn: &mut C, scope: &str) -> Result<Version, diesel::result::Error>
where
C: diesel::connection::LoadConnection<Backend = diesel::pg::Pg>,
{
let record = sql_query("SELECT horizon AS version FROM autumn_sync_horizons WHERE scope = $1")
.bind::<Text, _>(scope)
.get_result::<PgVersionRecord>(conn)
.optional()?;
Ok(record.map_or(0, |r| r.version))
}
fn pg_latest_version<C>(conn: &mut C) -> Result<Version, diesel::result::Error>
where
C: diesel::connection::LoadConnection<Backend = diesel::pg::Pg>,
{
let record = sql_query("SELECT last_value, is_called FROM autumn_sync_version_seq")
.get_result::<PgSequenceRecord>(conn)?;
Ok(if record.is_called {
record.last_value
} else {
0
})
}
#[derive(Debug, Clone)]
pub struct PgSyncBackend {
database_url: String,
}
macro_rules! with_sync_pg_connection {
($url:expr, |$conn:ident| $body:expr) => {
match crate::db::establish_migration_connection($url).map_err(backend_err)? {
crate::db::MigrationConnection::Native(mut native) => {
let $conn = &mut native;
$body
}
crate::db::MigrationConnection::Rustls { mut conn, runtime } => {
let result = {
let $conn = &mut conn;
$body
};
drop(conn);
drop(runtime);
result
}
}
};
}
impl PgSyncBackend {
#[must_use]
pub fn new(database_url: impl Into<String>) -> Self {
Self {
database_url: database_url.into(),
}
}
pub fn ensure_schema(&self) -> Result<(), SyncError> {
use diesel::connection::SimpleConnection;
with_sync_pg_connection!(&self.database_url, |conn| {
conn.batch_execute(PG_SCHEMA_DDL).map_err(backend_err)
})
}
}
impl SyncBackend for PgSyncBackend {
fn apply_push(
&self,
scope: &str,
request: &PushRequest,
resolver: &dyn ConflictResolver,
) -> Result<PushResponse, SyncError> {
validate_push(request)?;
with_sync_pg_connection!(&self.database_url, |conn| {
conn.transaction::<_, diesel::result::Error, _>(|conn| {
sql_query("SELECT pg_advisory_xact_lock($1)")
.bind::<BigInt, _>(PG_PUSH_ADVISORY_LOCK_KEY)
.execute(conn)?;
let horizon = pg_horizon(conn, scope)?;
let mut outcomes = Vec::with_capacity(request.changes.len());
for change in &request.changes {
let duplicate = sql_query(
"SELECT version, resolved_row FROM autumn_sync_applied \
WHERE scope = $1 AND device_id = $2 AND change_id = $3",
)
.bind::<Text, _>(scope)
.bind::<Text, _>(&request.device_id)
.bind::<Text, _>(&change.change_id)
.get_result::<PgAppliedRecord>(conn)
.optional()?;
if let Some(record) = duplicate {
outcomes.push(match record.resolved_row {
Some(value) => ChangeOutcome::Resolved {
row: serde_json::from_value(value).map_err(|e| {
diesel::result::Error::DeserializationError(e.into())
})?,
},
None => ChangeOutcome::AlreadyApplied {
version: record.version,
},
});
continue;
}
let current = sql_query(
"SELECT collection, pk, payload, version, deleted, updated_at, device_id, \
created_version \
FROM autumn_sync_rows \
WHERE scope = $1 AND collection = $2 AND pk = $3 FOR UPDATE",
)
.bind::<Text, _>(scope)
.bind::<Text, _>(&change.collection)
.bind::<Text, _>(&change.pk)
.get_result::<PgRowRecord>(conn)
.optional()?
.map(PgRowRecord::into_server_row);
let version = pg_next_version(conn)?;
let (stored, clean) = apply_change_row(
change,
&request.device_id,
current.as_ref(),
horizon,
resolver,
version,
);
pg_upsert_row(conn, scope, &stored)?;
let outcome = if clean {
ChangeOutcome::Applied { version }
} else {
ChangeOutcome::Resolved { row: stored.row }
};
let resolved_row = match &outcome {
ChangeOutcome::Resolved { row } => Some(
serde_json::to_value(row)
.map_err(|e| diesel::result::Error::SerializationError(e.into()))?,
),
_ => None,
};
sql_query(
"INSERT INTO autumn_sync_applied \
(scope, device_id, change_id, version, resolved_row) \
VALUES ($1, $2, $3, $4, $5)",
)
.bind::<Text, _>(scope)
.bind::<Text, _>(&request.device_id)
.bind::<Text, _>(&change.change_id)
.bind::<BigInt, _>(version)
.bind::<Nullable<Jsonb>, _>(resolved_row)
.execute(conn)?;
outcomes.push(outcome);
}
Ok(PushResponse { outcomes })
})
.map_err(backend_err)
})
}
fn pull_since(
&self,
scope: &str,
cursor: Version,
limit: i64,
session_start: Version,
) -> Result<PullResponse, SyncError> {
with_sync_pg_connection!(&self.database_url, |conn| {
conn.transaction::<_, diesel::result::Error, _>(|conn| {
sql_query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY")
.execute(conn)?;
let horizon = pg_horizon(conn, scope)?;
if session_start > 0 && session_start < horizon {
return Ok(PullResponse::FullResyncRequired {
tombstone_horizon: horizon,
});
}
let rows: Vec<RemoteRow> = sql_query(
"SELECT collection, pk, payload, version, deleted, updated_at, device_id, \
created_version \
FROM autumn_sync_rows \
WHERE scope = $1 AND version > $2 ORDER BY version LIMIT $3",
)
.bind::<Text, _>(scope)
.bind::<BigInt, _>(cursor)
.bind::<BigInt, _>(limit.max(0))
.get_results::<PgRowRecord>(conn)?
.into_iter()
.map(|record| record.into_server_row().row)
.collect();
let next_cursor = rows.last().map_or(cursor, |row| row.version);
Ok(PullResponse::Ok {
rows,
next_cursor,
tombstone_horizon: horizon,
})
})
.map_err(backend_err)
})
}
fn gc_tombstones(&self, up_to: Version) -> Result<u64, SyncError> {
with_sync_pg_connection!(&self.database_url, |conn| {
conn.transaction::<_, diesel::result::Error, _>(|conn| {
sql_query("SELECT pg_advisory_xact_lock($1)")
.bind::<BigInt, _>(PG_PUSH_ADVISORY_LOCK_KEY)
.execute(conn)?;
let dropped = sql_query(
"DELETE FROM autumn_sync_rows WHERE deleted AND version <= $1 \
RETURNING scope, version",
)
.bind::<BigInt, _>(up_to)
.get_results::<PgDroppedRecord>(conn)?;
let removed = dropped.len();
let mut per_scope: HashMap<String, Version> = HashMap::new();
for record in dropped {
let max = per_scope.entry(record.scope).or_insert(0);
*max = (*max).max(record.version);
}
for (scope, horizon) in per_scope {
sql_query(
"INSERT INTO autumn_sync_horizons (scope, horizon) VALUES ($1, $2) \
ON CONFLICT (scope) DO UPDATE SET \
horizon = GREATEST(autumn_sync_horizons.horizon, excluded.horizon)",
)
.bind::<Text, _>(&scope)
.bind::<BigInt, _>(horizon)
.execute(conn)?;
}
Ok(removed as u64)
})
.map_err(backend_err)
})
}
fn gc_applied(&self, older_than: DateTime<Utc>) -> Result<u64, SyncError> {
with_sync_pg_connection!(&self.database_url, |conn| {
let removed = sql_query("DELETE FROM autumn_sync_applied WHERE applied_at < $1")
.bind::<Timestamptz, _>(older_than)
.execute(conn)
.map_err(backend_err)?;
Ok(removed as u64)
})
}
fn tombstone_horizon(&self, scope: &str) -> Result<Version, SyncError> {
with_sync_pg_connection!(&self.database_url, |conn| {
pg_horizon(conn, scope).map_err(backend_err)
})
}
fn latest_version(&self) -> Result<Version, SyncError> {
with_sync_pg_connection!(&self.database_url, |conn| {
pg_latest_version(conn).map_err(backend_err)
})
}
}
pub fn router<S>(
backend: Arc<dyn SyncBackend>,
resolver: Arc<dyn ConflictResolver>,
) -> axum::Router<S>
where
S: Clone + Send + Sync + 'static,
{
build_router(backend, resolver, false)
}
pub fn scoped_router<S>(
backend: Arc<dyn SyncBackend>,
resolver: Arc<dyn ConflictResolver>,
) -> axum::Router<S>
where
S: Clone + Send + Sync + 'static,
{
build_router(backend, resolver, true)
}
#[must_use]
pub fn constant_time_token_eq(presented: &str, expected: &str) -> bool {
use subtle::ConstantTimeEq;
presented.as_bytes().ct_eq(expected.as_bytes()).into()
}
fn request_scope(extension: Option<SyncScope>, require_scope: bool) -> Result<SyncScope, ()> {
match extension {
Some(scope) => Ok(scope),
None if require_scope => Err(()),
None => Ok(SyncScope::global()),
}
}
fn scope_missing_response() -> axum::response::Response {
tracing::error!(
"sync request reached scoped_router without a SyncScope request \
extension — the deployment's auth middleware must insert one \
(rejecting, fail-closed)"
);
SCOPE_MISSING_REJECTION.into_response()
}
fn build_router<S>(
backend: Arc<dyn SyncBackend>,
resolver: Arc<dyn ConflictResolver>,
require_scope: bool,
) -> axum::Router<S>
where
S: Clone + Send + Sync + 'static,
{
let push_backend = Arc::clone(&backend);
axum::Router::new()
.route(
"/push",
post(
move |scope: Option<SyncScope>, Json(request): Json<PushRequest>| {
let backend = Arc::clone(&push_backend);
let resolver = Arc::clone(&resolver);
async move {
let Ok(scope) = request_scope(scope, require_scope) else {
return scope_missing_response();
};
if request.changes.len() > MAX_PUSH_CHANGES {
return (
StatusCode::PAYLOAD_TOO_LARGE,
format!(
"push batch of {} changes exceeds the limit of \
{MAX_PUSH_CHANGES}",
request.changes.len()
),
)
.into_response();
}
let result = tokio::task::spawn_blocking(move || {
backend.apply_push(scope.as_str(), &request, resolver.as_ref())
})
.await;
respond(result)
}
},
),
)
.route(
"/pull",
get(
move |scope: Option<SyncScope>, Query(query): Query<PullQuery>| {
let backend = Arc::clone(&backend);
async move {
let Ok(scope) = request_scope(scope, require_scope) else {
return scope_missing_response();
};
let limit = query.limit.clamp(1, MAX_PULL_LIMIT);
let session_start = query.session_start();
let result = tokio::task::spawn_blocking(move || {
backend.pull_since(scope.as_str(), query.cursor, limit, session_start)
})
.await;
respond(result)
}
},
),
)
}
fn respond<T: serde::Serialize>(
result: Result<Result<T, SyncError>, tokio::task::JoinError>,
) -> axum::response::Response {
match result {
Ok(Ok(response)) => Json(response).into_response(),
Ok(Err(err @ SyncError::Protocol(_))) => {
tracing::warn!(error = %err, "sync request rejected");
(StatusCode::UNPROCESSABLE_ENTITY, err.to_string()).into_response()
}
Ok(Err(err)) => {
tracing::error!(error = %err, "sync request failed");
(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
}
Err(err) => {
tracing::error!(error = %err, "sync request panicked");
(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
}
}
}
#[cfg(test)]
mod tests {
#[test]
fn constant_time_token_eq_is_plain_equality() {
assert!(super::constant_time_token_eq("sync-secret", "sync-secret"));
assert!(!super::constant_time_token_eq("sync-secret", "sync-secreT"));
assert!(!super::constant_time_token_eq("sync", "sync-secret"));
assert!(!super::constant_time_token_eq("", "sync-secret"));
assert!(super::constant_time_token_eq("", ""));
}
}