M4: sync pull and push (E-31, E-32), audit writer and diff, FCM sender, push batching, scheduler, pruning, rotate-epoch

- src/sync/: pull with UNION ALL paging and cursor_ahead; push processing of SPEC §14.4.4 steps 1 to 12 in one
  BEGIN IMMEDIATE with a savepoint per mutation, LWW with delete-wins, tombstones, clock_skew, replay from
  sync_mutations, sync_batch for more than 3 notifiable changes; 90-day pruning.
- src/audit/: SqlAuditWriter and the §14.4.7 diff (now the default in AppState::new).
- src/push/: bounded PushHub (100, drop oldest), FCM HTTP v1 client with service-account JWT (RS256, jsonwebtoken
  on aws-lc-rs) and token cache, sender with 1/5/25 s retries and token clearing; disabled without credentials.
- src/tasks/: start installs push and spawns the 06:00 generation (and at start) and 04:00 pruning loops on the
  injectable clock.
- cli: rotate-epoch; serve uses into_make_service_with_connect_info; stale doc comments updated; /i/ empty code
  gets the HTML 404.
- tests: sync_api, push_fcm (local stub of Google's endpoints), scheduler_daily; skeleton routes test now asserts
  no route is 501.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-17 22:27:23 +02:00
parent 853c038c58
commit 15056dc7df
37 changed files with 6312 additions and 102 deletions
@@ -0,0 +1,231 @@
//! Step 8 of SPEC §14.4.4: a pushed expense or settlement against §9.6, §9.1 (server check) or §9.4 (settlement
//! validity), and its references.
//!
//! The order of the checks: field rules (§9.6), then the split or settlement rules with the domain error token as the
//! message (§14.5.3), then references (`reference_not_found`, message = the JSON key). The spec fixes no order between
//! these; a correct phone triggers none of them.
use std::collections::{BTreeMap, BTreeSet};
use chrono::{DateTime, Days, NaiveDate, Utc};
use kvitt_domain::SplitSpec;
use sqlx::SqliteConnection;
use crate::api::{rows, validate};
use crate::error::AppError;
use crate::time::{parse_date, stockholm_today};
use crate::wire::{PushedExpense, PushedSettlement, RejectionReason, SplitMode, User};
/// The earliest `expense_date` / `settlement_date` (SPEC §9.6).
pub const EARLIEST_DATE: NaiveDate = match NaiveDate::from_ymd_opt(2000, 1, 1) {
Some(d) => d,
None => NaiveDate::MIN,
};
/// How far after server today (Europe/Stockholm) a date may lie, in days (SPEC §9.6).
pub const MAX_DAYS_AHEAD: u64 = 365;
/// Settlement `note`: 0 to 200 code points (SPEC §9.6).
pub const SETTLEMENT_NOTE_MAX: usize = 200;
/// The message of a split whose `share_ore` differ from the recomputed ones (SPEC §9.1 server check).
pub const SHARE_MISMATCH: &str = "share_ore does not match the split rule";
/// A rejection of SPEC §14.4.6 with its developer message.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Rejection {
pub reason: RejectionReason,
pub message: String,
}
impl Rejection {
pub fn new(reason: RejectionReason, message: impl Into<String>) -> Self {
Self {
reason,
message: message.into(),
}
}
pub fn invalid(message: impl Into<String>) -> Self {
Self::new(RejectionReason::ValidationFailed, message)
}
}
/// Either a rejection of the one mutation or an error that fails the whole request.
#[derive(Debug)]
pub enum Fail {
Reject(Rejection),
Internal(AppError),
}
impl From<AppError> for Fail {
fn from(e: AppError) -> Self {
Self::Internal(e)
}
}
impl From<Rejection> for Fail {
fn from(r: Rejection) -> Self {
Self::Reject(r)
}
}
/// `validation_failed` from a §9.6 check (the REST helpers of `api::validate` report through `AppError`).
fn field_rule<T>(result: Result<T, AppError>) -> Result<T, Rejection> {
result.map_err(|e| Rejection::invalid(e.message))
}
/// A date of SPEC §9.6: valid, from 2000-01-01 to server today (Europe/Stockholm) + 365 days.
pub fn date(field: &str, text: &str, now: DateTime<Utc>) -> Result<(), Rejection> {
let date = parse_date(text)
.ok_or_else(|| Rejection::invalid(format!("{field} must be YYYY-MM-DD")))?;
let latest = stockholm_today(now)
.checked_add_days(Days::new(MAX_DAYS_AHEAD))
.unwrap_or(NaiveDate::MAX);
if date < EARLIEST_DATE || date > latest {
return Err(Rejection::invalid(format!(
"{field} must be from 2000-01-01 to today + 365 days"
)));
}
Ok(())
}
/// The split rules of SPEC §9.1: exactly one entry per household user, and the server check (shares recomputed with
/// `kvitt-domain`; for mode `default` the bp are only checked to sum to 10000, never against the account).
pub fn splits(expense: &PushedExpense, users: &[User]) -> Result<(), Rejection> {
let household: BTreeSet<String> = rows::user_ids(users).into_iter().collect();
let pushed: BTreeSet<String> = expense
.splits
.iter()
.map(|s| s.user_id.to_string())
.collect();
if expense.splits.len() != 2 || pushed != household {
return Err(Rejection::invalid(
kvitt_domain::SplitError::ShareUsersMismatch.token(),
));
}
let ids = rows::user_ids(users);
let payer = expense.payer_user_id.to_string();
let spec = match expense.split_mode {
SplitMode::Exact => {
if expense.splits.iter().any(|s| s.share_bp.is_some()) {
return Err(Rejection::invalid("share_bp must be null for mode exact"));
}
SplitSpec::Exact {
ore: expense
.splits
.iter()
.map(|s| (s.user_id.to_string(), s.share_ore))
.collect(),
}
}
SplitMode::Default | SplitMode::Ratio | SplitMode::Full => {
let mut bp = BTreeMap::new();
for split in &expense.splits {
let value = split.share_bp.ok_or_else(|| {
Rejection::invalid("share_bp is required for this split_mode")
})?;
let value = u32::try_from(value)
.ok()
.filter(|v| *v <= kvitt_domain::BP_TOTAL)
.ok_or_else(|| {
Rejection::invalid(kvitt_domain::SplitError::BpOutOfRange.token())
})?;
if expense.split_mode == SplitMode::Full
&& value != 0
&& value != kvitt_domain::BP_TOTAL
{
return Err(Rejection::invalid(
"mode full needs share_bp 10000 for the bearer and 0 for the other user",
));
}
bp.insert(split.user_id.to_string(), value);
}
SplitSpec::Ratio { bp }
}
};
let computed = kvitt_domain::compute_split(expense.amount_ore, &payer, &ids, &spec)
.map_err(|e| Rejection::invalid(e.token()))?;
let matches = expense
.splits
.iter()
.all(|s| computed.get(&s.user_id.to_string()) == Some(&s.share_ore));
if !matches {
return Err(Rejection::invalid(SHARE_MISMATCH));
}
Ok(())
}
/// A non-deleted account (archived is fine), else `reference_not_found` with `field` as the message.
async fn live_account(
conn: &mut SqliteConnection,
id: uuid::Uuid,
field: &str,
) -> Result<(), Fail> {
match rows::account(conn, id).await? {
Some(a) if a.deleted_at.is_none() => Ok(()),
_ => Err(Rejection::new(RejectionReason::ReferenceNotFound, field).into()),
}
}
/// Validates a pushed expense; returns it with `description` and `notes` trimmed (the stored form, SPEC §8.6).
pub async fn expense(
conn: &mut SqliteConnection,
pushed: &PushedExpense,
users: &[User],
now: DateTime<Utc>,
) -> Result<PushedExpense, Fail> {
let mut checked = pushed.clone();
checked.amount_ore = field_rule(validate::amount("amount_ore", pushed.amount_ore))?;
checked.description = field_rule(validate::text(
"description",
&pushed.description,
1,
validate::DESCRIPTION_MAX,
))?;
checked.notes = field_rule(validate::notes("notes", &pushed.notes))?;
date("expense_date", &pushed.expense_date, now)?;
if !users.iter().any(|u| u.id == pushed.payer_user_id) {
return Err(Rejection::invalid(kvitt_domain::SplitError::PayerUnknown.token()).into());
}
splits(&checked, users)?;
live_account(conn, pushed.account_id, "account_id").await?;
match rows::category(conn, pushed.category_id).await? {
Some(c) if c.deleted_at.is_none() => {}
_ => {
return Err(Rejection::new(RejectionReason::ReferenceNotFound, "category_id").into());
}
}
checked.splits.sort_by_key(|s| s.user_id);
Ok(checked)
}
/// Validates a pushed settlement (SPEC §9.4 settlement validity, §9.6); returns it with `note` trimmed.
pub async fn settlement(
conn: &mut SqliteConnection,
pushed: &PushedSettlement,
users: &[User],
now: DateTime<Utc>,
) -> Result<PushedSettlement, Fail> {
let mut checked = pushed.clone();
checked.note = field_rule(validate::text("note", &pushed.note, 0, SETTLEMENT_NOTE_MAX))?;
date("settlement_date", &pushed.settlement_date, now)?;
let allocations: Vec<kvitt_domain::Allocation> = pushed
.allocations
.iter()
.map(|a| kvitt_domain::Allocation {
account_id: a.account_id.to_string(),
amount_ore: a.amount_ore,
})
.collect();
kvitt_domain::validate_settlement(
pushed.amount_ore,
&pushed.from_user_id.to_string(),
&pushed.to_user_id.to_string(),
&rows::user_ids(users),
&allocations,
)
.map_err(|e| Rejection::invalid(e.token()))?;
for allocation in &pushed.allocations {
live_account(conn, allocation.account_id, "allocations").await?;
}
checked.allocations.sort_by_key(|a| a.account_id);
Ok(checked)
}
@@ -0,0 +1,31 @@
//! The sync protocol v1 (SPEC §14.4): pull ([`pull`]), push processing ([`push`]) with the validation of step 8
//! ([`check`]), and the daily pruning of `sync_mutations` ([`prune_mutations`]). The HTTP handlers are in
//! `api/sync.rs`.
pub mod check;
pub mod pull;
pub mod push;
pub mod rows;
use chrono::{DateTime, Duration, Utc};
use crate::db::{self, Db};
use crate::error::AppError;
use crate::time::format_ts;
/// How long `sync_mutations` rows are kept (SPEC §14.4.9, D-021).
pub const MUTATION_RETENTION: Duration = Duration::days(90);
/// Deletes the `sync_mutations` rows whose `received_at` is older than 90 days before `now` (SPEC §14.1). Returns
/// the number of rows deleted. Timestamps are in the fixed RFC 3339 form, so text order is time order.
pub async fn prune_mutations(db: &Db, now: DateTime<Utc>) -> Result<u64, AppError> {
let cutoff = format_ts(now - MUTATION_RETENTION);
let mut tx = db::begin_immediate(db).await?;
let deleted = sqlx::query("DELETE FROM sync_mutations WHERE received_at < ?")
.bind(cutoff)
.execute(&mut *tx)
.await?
.rows_affected();
tx.commit().await?;
Ok(deleted)
}
+120
View File
@@ -0,0 +1,120 @@
//! E-31 pull (SPEC §14.4.2).
use crate::AppState;
use crate::api::rows;
use crate::db::sequence::current_server_version;
use crate::error::{AppError, ErrorCode};
use crate::time::format_ts;
use crate::wire::SyncPullResponse;
use super::rows as sync_rows;
/// Rows per page when `limit` is absent (SPEC §14.4.9).
pub const DEFAULT_LIMIT: i64 = 500;
/// Largest `limit` (SPEC §9.6).
pub const MAX_LIMIT: i64 = 1000;
/// The seven synced tables as one `(server_version, table, id)` stream, tombstones included. Child rows (splits,
/// allocations, default shares, template splits) have no version of their own: they travel with their parent.
const ROW_STREAM: &str = "\
SELECT server_version AS v, 'users' AS t, id FROM users WHERE server_version > ?1 \
UNION ALL SELECT server_version, 'categories', id FROM categories WHERE server_version > ?1 \
UNION ALL SELECT server_version, 'accounts', id FROM accounts WHERE server_version > ?1 \
UNION ALL SELECT server_version, 'recurring_templates', id FROM recurring_templates WHERE server_version > ?1 \
UNION ALL SELECT server_version, 'expenses', id FROM expenses WHERE server_version > ?1 \
UNION ALL SELECT server_version, 'settlements', id FROM settlements WHERE server_version > ?1 \
UNION ALL SELECT server_version, 'audit_log', id FROM audit_log WHERE server_version > ?1 \
ORDER BY v LIMIT ?2";
/// Checks `limit` (1 to 1000, SPEC §9.6) and returns it or the default.
pub fn page_size(limit: Option<i64>) -> Result<i64, AppError> {
match limit {
None => Ok(DEFAULT_LIMIT),
Some(l) if (1..=MAX_LIMIT).contains(&l) => Ok(l),
Some(_) => Err(AppError::invalid_request("limit must be 1 to 1000").with_field("limit")),
}
}
/// One page of rows with `server_version > since`. Runs in one read transaction, so the page, the cursor check and
/// `has_more` see the same snapshot.
pub async fn pull(state: &AppState, since: i64, limit: i64) -> Result<SyncPullResponse, AppError> {
let mut tx = state.db.begin().await?;
let current = current_server_version(&mut tx).await?;
if since > current {
return Err(AppError::new(
ErrorCode::CursorAhead,
"since is greater than the server's current sequence value",
)
.with_field("since"));
}
let epoch = sync_rows::epoch(&mut tx).await?;
// One row more than the page tells whether more exist.
let mut stream: Vec<(i64, String, String)> = sqlx::query_as(ROW_STREAM)
.bind(since)
.bind(limit + 1)
.fetch_all(&mut *tx)
.await?;
let has_more = i64::try_from(stream.len()).map_err(AppError::internal)? > limit;
stream.truncate(usize::try_from(limit).map_err(AppError::internal)?);
let next_since = stream.last().map_or(since, |(v, _, _)| *v);
let mut page = SyncPullResponse {
epoch,
since,
next_since,
has_more,
server_time: format_ts(state.clock.now()),
users: Vec::new(),
categories: Vec::new(),
accounts: Vec::new(),
recurring_templates: Vec::new(),
expenses: Vec::new(),
settlements: Vec::new(),
audit_entries: Vec::new(),
};
let missing = |table: &str| AppError::internal(format!("{table} row vanished during pull"));
// The stream is ordered by server_version, so every array is too.
for (_, table, id) in stream {
let id = rows::uuid(&id)?;
match table.as_str() {
"users" => page.users.push(
rows::user(&mut tx, id)
.await?
.ok_or_else(|| missing("users"))?,
),
"categories" => page.categories.push(
rows::category(&mut tx, id)
.await?
.ok_or_else(|| missing("categories"))?,
),
"accounts" => page.accounts.push(
rows::account(&mut tx, id)
.await?
.ok_or_else(|| missing("accounts"))?,
),
"recurring_templates" => page.recurring_templates.push(
rows::template(&mut tx, id)
.await?
.ok_or_else(|| missing("recurring_templates"))?,
),
"expenses" => page.expenses.push(
rows::expense(&mut tx, id)
.await?
.ok_or_else(|| missing("expenses"))?,
),
"settlements" => page.settlements.push(
sync_rows::settlement(&mut tx, id)
.await?
.ok_or_else(|| missing("settlements"))?,
),
"audit_log" => page.audit_entries.push(
sync_rows::audit_entry(&mut tx, id)
.await?
.ok_or_else(|| missing("audit_log"))?,
),
other => return Err(AppError::internal(format!("unknown table {other}"))),
}
}
tx.commit().await?;
Ok(page)
}
+807
View File
@@ -0,0 +1,807 @@
//! E-32 push (SPEC §14.4.3, §14.4.4): the whole request in one `BEGIN IMMEDIATE` transaction, each mutation in its
//! own savepoint (rolled back on rejection), the result of every mutation recorded in `sync_mutations` (step 12),
//! pushes enqueued after commit (more than [`SYNC_BATCH_THRESHOLD`] become one `sync_batch`, AC-13.4).
use std::future::Future;
use chrono::{DateTime, Duration, Utc};
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::Value;
use sqlx::{Acquire, SqliteConnection};
use uuid::Uuid;
use crate::AppState;
use crate::api::rows;
use crate::audit::AuditEvent;
use crate::auth::AuthDevice;
use crate::db;
use crate::error::{AppError, ErrorCode};
use crate::push::{PushMessage, PushType};
use crate::time::{format_ts, parse_ts};
use crate::wire::sync::{AcceptedTag, RejectedTag, SyncEntity};
use crate::wire::{
AcceptedResult, AuditAction, AuditSource, EntityType, Expense, MutationResult, PushedExpense,
PushedSettlement, RejectedResult, RejectionReason, Settlement, User,
};
use super::check::{self, Fail, Rejection};
use super::rows as sync_rows;
/// Mutations per `POST /sync` (SPEC §14.4.9).
pub const MAX_MUTATIONS: usize = 100;
/// How far ahead of server now a pushed `updated_at` may lie (SPEC §9.6, step 4).
pub const CLOCK_SKEW_LIMIT: Duration = Duration::minutes(10);
/// More notifiable changes than this in one request send one `sync_batch` instead (AC-13.4).
pub const SYNC_BATCH_THRESHOLD: usize = 3;
/// The E-32 response body. Results are kept as JSON so a replay returns the stored result byte for byte in value.
#[derive(Debug, Clone, Serialize)]
pub struct PushResponse {
pub server_time: String,
pub results: Vec<Value>,
}
/// The keys every mutation must have for a per-mutation result: `mutation_id`, `entity_type` and `entity.id`.
struct Envelope {
mutation_id: Uuid,
entity_type: EntityType,
entity_id: Uuid,
entity: Value,
}
/// A UUID in the canonical form the server stores and returns (lowercase, hyphenated).
fn canonical_uuid(value: Option<&Value>) -> Option<Uuid> {
let text = value?.as_str()?;
Uuid::parse_str(text)
.ok()
.filter(|id| id.hyphenated().to_string() == text)
}
fn envelope(index: usize, raw: Value) -> Result<Envelope, AppError> {
let bad = |what: &str| {
AppError::invalid_request(format!("mutations[{index}]: {what}")).with_field("mutations")
};
let mutation_id = canonical_uuid(raw.get("mutation_id"))
.ok_or_else(|| bad("mutation_id must be a lowercase hyphenated uuid"))?;
let entity_type = match raw.get("entity_type").and_then(Value::as_str) {
Some("expense") => EntityType::Expense,
Some("settlement") => EntityType::Settlement,
_ => return Err(bad("entity_type must be expense or settlement")),
};
let entity = raw.get("entity").cloned().unwrap_or(Value::Null);
let entity_id = canonical_uuid(entity.get("id"))
.ok_or_else(|| bad("entity.id must be a lowercase hyphenated uuid"))?;
Ok(Envelope {
mutation_id,
entity_type,
entity_id,
entity,
})
}
/// The id-valued keys of a pushed entity that must be canonical UUIDs (step 3).
fn id_paths(
entity_type: EntityType,
) -> (
&'static [&'static str],
&'static str,
&'static [&'static str],
) {
match entity_type {
EntityType::Expense => (
&["id", "account_id", "category_id", "payer_user_id"],
"splits",
&["user_id"],
),
EntityType::Settlement => (
&["id", "from_user_id", "to_user_id"],
"allocations",
&["account_id"],
),
}
}
/// Step 3 checks serde cannot express: ids in canonical form and timestamps in the fixed RFC 3339 form.
fn shape(entity_type: EntityType, entity: &Value) -> Result<(), Rejection> {
let (top, child_key, child_ids) = id_paths(entity_type);
for key in top {
if canonical_uuid(entity.get(*key)).is_none() {
return Err(Rejection::invalid(format!(
"{key} must be a lowercase hyphenated uuid"
)));
}
}
if let Some(children) = entity.get(child_key).and_then(Value::as_array) {
for child in children {
for key in child_ids {
if canonical_uuid(child.get(*key)).is_none() {
return Err(Rejection::invalid(format!(
"{child_key}[].{key} must be a lowercase hyphenated uuid"
)));
}
}
}
}
for key in ["created_at", "updated_at"] {
let ok = entity
.get(key)
.and_then(Value::as_str)
.is_some_and(|t| parse_ts(t).is_some());
if !ok {
return Err(Rejection::invalid(format!(
"{key} must be a timestamp in the fixed RFC 3339 form"
)));
}
}
match entity.get("deleted_at") {
Some(Value::Null) => {}
Some(Value::String(t)) if parse_ts(t).is_some() => {}
_ => {
return Err(Rejection::invalid(
"deleted_at must be null or a timestamp in the fixed RFC 3339 form",
));
}
}
Ok(())
}
/// What differs between expenses and settlements in the steps of §14.4.4.
trait Kind {
type Pushed: DeserializeOwned + Serialize + Clone + Send + Sync;
type Wire: Serialize + Clone + Send + Sync;
const ENTITY: EntityType;
/// Step 7 on a stored row: `deleted_at`, `updated_at`, `updated_by_user_id`, `updated_by_device_id`,
/// `server_version`, by `id`.
const DELETE_SQL: &'static str;
const CREATED: PushType;
const UPDATED: PushType;
const DELETED: PushType;
fn updated_at(p: &Self::Pushed) -> &str;
fn deleted_at(p: &Self::Pushed) -> Option<&str>;
fn stored_updated_at(w: &Self::Wire) -> &str;
fn stored_deleted_at(w: &Self::Wire) -> Option<&str>;
fn entity(w: Self::Wire) -> SyncEntity;
fn load(
conn: &mut SqliteConnection,
id: Uuid,
) -> impl Future<Output = Result<Option<Self::Wire>, AppError>> + Send;
fn check(
conn: &mut SqliteConnection,
p: &Self::Pushed,
users: &[User],
now: DateTime<Utc>,
) -> impl Future<Output = Result<Self::Pushed, Fail>> + Send;
/// Inserts the row and its children (a tombstone when `deleted_at` is set).
fn insert(
conn: &mut SqliteConnection,
p: &Self::Pushed,
caller: &AuthDevice,
version: i64,
now_ts: &str,
) -> impl Future<Output = Result<(), AppError>> + Send;
/// Replaces the client-writable fields (except `created_at`) and the children; server-owned fields keep their
/// stored values.
fn update(
conn: &mut SqliteConnection,
p: &Self::Pushed,
caller: &AuthDevice,
version: i64,
) -> impl Future<Output = Result<(), AppError>> + Send;
}
struct ExpenseKind;
struct SettlementKind;
impl Kind for ExpenseKind {
type Pushed = PushedExpense;
type Wire = Expense;
const ENTITY: EntityType = EntityType::Expense;
const DELETE_SQL: &'static str = "UPDATE expenses SET deleted_at = ?, updated_at = ?, updated_by_user_id = ?, \
updated_by_device_id = ?, server_version = ? WHERE id = ?";
const CREATED: PushType = PushType::ExpenseCreated;
const UPDATED: PushType = PushType::ExpenseUpdated;
const DELETED: PushType = PushType::ExpenseDeleted;
fn updated_at(p: &PushedExpense) -> &str {
&p.updated_at
}
fn deleted_at(p: &PushedExpense) -> Option<&str> {
p.deleted_at.as_deref()
}
fn stored_updated_at(w: &Expense) -> &str {
&w.updated_at
}
fn stored_deleted_at(w: &Expense) -> Option<&str> {
w.deleted_at.as_deref()
}
fn entity(w: Expense) -> SyncEntity {
SyncEntity::Expense(w)
}
async fn load(conn: &mut SqliteConnection, id: Uuid) -> Result<Option<Expense>, AppError> {
rows::expense(conn, id).await
}
async fn check(
conn: &mut SqliteConnection,
p: &PushedExpense,
users: &[User],
now: DateTime<Utc>,
) -> Result<PushedExpense, Fail> {
check::expense(conn, p, users, now).await
}
async fn insert(
conn: &mut SqliteConnection,
p: &PushedExpense,
caller: &AuthDevice,
version: i64,
now_ts: &str,
) -> Result<(), AppError> {
sqlx::query(
"INSERT INTO expenses (id, account_id, category_id, amount_ore, description, notes, expense_date, \
payer_user_id, split_mode, recurring_template_id, recurring_period, import_key, created_by_user_id, \
updated_by_user_id, updated_by_device_id, created_at, updated_at, deleted_at, server_received_at, \
server_version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(p.id.to_string())
.bind(p.account_id.to_string())
.bind(p.category_id.to_string())
.bind(p.amount_ore)
.bind(&p.description)
.bind(&p.notes)
.bind(&p.expense_date)
.bind(p.payer_user_id.to_string())
.bind(rows::token_of(&p.split_mode)?)
.bind(caller.user_id.to_string())
.bind(caller.user_id.to_string())
.bind(caller.device_id.to_string())
.bind(&p.created_at)
.bind(&p.updated_at)
.bind(&p.deleted_at)
.bind(now_ts)
.bind(version)
.execute(&mut *conn)
.await?;
insert_splits(conn, p).await
}
async fn update(
conn: &mut SqliteConnection,
p: &PushedExpense,
caller: &AuthDevice,
version: i64,
) -> Result<(), AppError> {
sqlx::query(
"UPDATE expenses SET account_id = ?, category_id = ?, amount_ore = ?, description = ?, notes = ?, \
expense_date = ?, payer_user_id = ?, split_mode = ?, updated_at = ?, updated_by_user_id = ?, \
updated_by_device_id = ?, server_version = ? WHERE id = ?",
)
.bind(p.account_id.to_string())
.bind(p.category_id.to_string())
.bind(p.amount_ore)
.bind(&p.description)
.bind(&p.notes)
.bind(&p.expense_date)
.bind(p.payer_user_id.to_string())
.bind(rows::token_of(&p.split_mode)?)
.bind(&p.updated_at)
.bind(caller.user_id.to_string())
.bind(caller.device_id.to_string())
.bind(version)
.bind(p.id.to_string())
.execute(&mut *conn)
.await?;
sqlx::query("DELETE FROM expense_splits WHERE expense_id = ?")
.bind(p.id.to_string())
.execute(&mut *conn)
.await?;
insert_splits(conn, p).await
}
}
async fn insert_splits(conn: &mut SqliteConnection, p: &PushedExpense) -> Result<(), AppError> {
for split in &p.splits {
sqlx::query(
"INSERT INTO expense_splits (expense_id, user_id, share_ore, share_bp) VALUES (?, ?, ?, ?)",
)
.bind(p.id.to_string())
.bind(split.user_id.to_string())
.bind(split.share_ore)
.bind(split.share_bp)
.execute(&mut *conn)
.await?;
}
Ok(())
}
impl Kind for SettlementKind {
type Pushed = PushedSettlement;
type Wire = Settlement;
const ENTITY: EntityType = EntityType::Settlement;
const DELETE_SQL: &'static str = "UPDATE settlements SET deleted_at = ?, updated_at = ?, updated_by_user_id = ?, \
updated_by_device_id = ?, server_version = ? WHERE id = ?";
const CREATED: PushType = PushType::SettlementCreated;
const UPDATED: PushType = PushType::SettlementUpdated;
const DELETED: PushType = PushType::SettlementDeleted;
fn updated_at(p: &PushedSettlement) -> &str {
&p.updated_at
}
fn deleted_at(p: &PushedSettlement) -> Option<&str> {
p.deleted_at.as_deref()
}
fn stored_updated_at(w: &Settlement) -> &str {
&w.updated_at
}
fn stored_deleted_at(w: &Settlement) -> Option<&str> {
w.deleted_at.as_deref()
}
fn entity(w: Settlement) -> SyncEntity {
SyncEntity::Settlement(w)
}
async fn load(conn: &mut SqliteConnection, id: Uuid) -> Result<Option<Settlement>, AppError> {
sync_rows::settlement(conn, id).await
}
async fn check(
conn: &mut SqliteConnection,
p: &PushedSettlement,
users: &[User],
now: DateTime<Utc>,
) -> Result<PushedSettlement, Fail> {
check::settlement(conn, p, users, now).await
}
async fn insert(
conn: &mut SqliteConnection,
p: &PushedSettlement,
caller: &AuthDevice,
version: i64,
now_ts: &str,
) -> Result<(), AppError> {
sqlx::query(
"INSERT INTO settlements (id, from_user_id, to_user_id, amount_ore, settlement_date, note, import_key, \
created_by_user_id, updated_by_user_id, updated_by_device_id, created_at, updated_at, deleted_at, \
server_received_at, server_version) VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(p.id.to_string())
.bind(p.from_user_id.to_string())
.bind(p.to_user_id.to_string())
.bind(p.amount_ore)
.bind(&p.settlement_date)
.bind(&p.note)
.bind(caller.user_id.to_string())
.bind(caller.user_id.to_string())
.bind(caller.device_id.to_string())
.bind(&p.created_at)
.bind(&p.updated_at)
.bind(&p.deleted_at)
.bind(now_ts)
.bind(version)
.execute(&mut *conn)
.await?;
insert_allocations(conn, p).await
}
async fn update(
conn: &mut SqliteConnection,
p: &PushedSettlement,
caller: &AuthDevice,
version: i64,
) -> Result<(), AppError> {
sqlx::query(
"UPDATE settlements SET from_user_id = ?, to_user_id = ?, amount_ore = ?, settlement_date = ?, \
note = ?, updated_at = ?, updated_by_user_id = ?, updated_by_device_id = ?, server_version = ? \
WHERE id = ?",
)
.bind(p.from_user_id.to_string())
.bind(p.to_user_id.to_string())
.bind(p.amount_ore)
.bind(&p.settlement_date)
.bind(&p.note)
.bind(&p.updated_at)
.bind(caller.user_id.to_string())
.bind(caller.device_id.to_string())
.bind(version)
.bind(p.id.to_string())
.execute(&mut *conn)
.await?;
sqlx::query("DELETE FROM settlement_allocations WHERE settlement_id = ?")
.bind(p.id.to_string())
.execute(&mut *conn)
.await?;
insert_allocations(conn, p).await
}
}
async fn insert_allocations(
conn: &mut SqliteConnection,
p: &PushedSettlement,
) -> Result<(), AppError> {
for allocation in &p.allocations {
sqlx::query(
"INSERT INTO settlement_allocations (settlement_id, account_id, amount_ore) VALUES (?, ?, ?)",
)
.bind(p.id.to_string())
.bind(allocation.account_id.to_string())
.bind(allocation.amount_ore)
.execute(&mut *conn)
.await?;
}
Ok(())
}
/// The outcome of one mutation: its result and the push it causes, if any.
struct Processed {
result: MutationResult,
push: Option<PushMessage>,
}
/// The context every mutation of one request shares.
struct Ctx<'a> {
state: &'a AppState,
caller: &'a AuthDevice,
users: &'a [User],
now: DateTime<Utc>,
now_ts: String,
}
fn accepted(env: &Envelope, entity: SyncEntity) -> MutationResult {
let server_version = match &entity {
SyncEntity::Expense(e) => e.server_version,
SyncEntity::Settlement(s) => s.server_version,
};
MutationResult::Accepted(AcceptedResult {
mutation_id: env.mutation_id,
entity_type: env.entity_type,
entity_id: env.entity_id,
status: AcceptedTag::Accepted,
server_version,
entity,
})
}
fn rejected(env: &Envelope, rejection: Rejection, current: Option<SyncEntity>) -> MutationResult {
MutationResult::Rejected(RejectedResult {
mutation_id: env.mutation_id,
entity_type: env.entity_type,
entity_id: env.entity_id,
status: RejectedTag::Rejected,
reason: rejection.reason,
message: rejection.message,
current,
})
}
fn push_of(push_type: PushType, env: &Envelope, ctx: &Ctx<'_>) -> Option<PushMessage> {
Some(PushMessage {
push_type,
entity_id: Some(env.entity_id),
actor_user_id: ctx.caller.user_id,
count: 1,
})
}
async fn reload<K: Kind>(conn: &mut SqliteConnection, id: Uuid) -> Result<K::Wire, AppError> {
K::load(conn, id)
.await?
.ok_or_else(|| AppError::internal("row vanished inside its transaction"))
}
fn json<T: Serialize>(value: &T) -> Result<Value, AppError> {
serde_json::to_value(value).map_err(AppError::internal)
}
async fn audit<K: Kind>(
conn: &mut SqliteConnection,
ctx: &Ctx<'_>,
env: &Envelope,
action: AuditAction,
client_at: &str,
before: Option<&K::Wire>,
after: &K::Wire,
) -> Result<(), AppError> {
ctx.state
.audit
.write(
conn,
AuditEvent {
entity_type: K::ENTITY,
entity_id: env.entity_id,
action,
source: AuditSource::App,
actor_user_id: ctx.caller.user_id,
actor_device_id: Some(ctx.caller.device_id),
client_at: client_at.to_owned(),
server_at: ctx.now_ts.clone(),
before: before.map(json).transpose()?,
after: json(after)?,
},
)
.await?;
Ok(())
}
/// Steps 3 to 11 of SPEC §14.4.4 for one mutation, on the mutation's savepoint.
async fn process<K: Kind>(
conn: &mut SqliteConnection,
ctx: &Ctx<'_>,
env: &Envelope,
) -> Result<Processed, AppError> {
// Every rejection carries the stored row (or null) as `current`; load it lazily.
async fn current<K: Kind>(
conn: &mut SqliteConnection,
id: Uuid,
) -> Result<Option<SyncEntity>, AppError> {
Ok(K::load(conn, id).await?.map(K::entity))
}
let reject = |rejection: Rejection, current: Option<SyncEntity>| Processed {
result: rejected(env, rejection, current),
push: None,
};
// 3. Shape.
let pushed: K::Pushed = match shape(K::ENTITY, &env.entity).and_then(|()| {
serde_json::from_value(env.entity.clone()).map_err(|e| Rejection::invalid(e.to_string()))
}) {
Ok(p) => p,
Err(r) => {
let cur = current::<K>(conn, env.entity_id).await?;
return Ok(reject(r, cur));
}
};
// 4. Clock.
let updated_at = parse_ts(K::updated_at(&pushed)).ok_or_else(|| {
AppError::internal("updated_at passed the shape check but does not parse")
})?;
if updated_at > ctx.now + CLOCK_SKEW_LIMIT {
let cur = current::<K>(conn, env.entity_id).await?;
return Ok(reject(
Rejection::new(
RejectionReason::ClockSkew,
"updated_at is more than 10 minutes ahead of server time",
),
cur,
));
}
// 5. Load.
let stored = K::load(conn, env.entity_id).await?;
// 6. Stored row is a tombstone.
if let Some(tombstone) = stored
.as_ref()
.filter(|s| K::stored_deleted_at(s).is_some())
{
let entity = K::entity(tombstone.clone());
return Ok(if K::deleted_at(&pushed).is_some() {
Processed {
result: accepted(env, entity),
push: None,
}
} else {
reject(
Rejection::new(RejectionReason::Deleted, "the row is deleted"),
Some(entity),
)
});
}
// 7. Pushed entity is a delete: always wins.
if let Some(deleted_at) = K::deleted_at(&pushed).map(str::to_owned) {
if let Some(stored) = stored {
let stored_updated = parse_ts(K::stored_updated_at(&stored))
.ok_or_else(|| AppError::internal("stored updated_at does not parse"))?;
let new_updated_at = format_ts(stored_updated.max(updated_at));
let version = db::next_server_version(conn).await?;
sqlx::query(K::DELETE_SQL)
.bind(&deleted_at)
.bind(&new_updated_at)
.bind(ctx.caller.user_id.to_string())
.bind(ctx.caller.device_id.to_string())
.bind(version)
.bind(env.entity_id.to_string())
.execute(&mut *conn)
.await?;
let after = reload::<K>(conn, env.entity_id).await?;
audit::<K>(
conn,
ctx,
env,
AuditAction::Delete,
&deleted_at,
Some(&stored),
&after,
)
.await?;
return Ok(Processed {
result: accepted(env, K::entity(after)),
push: push_of(K::DELETED, env, ctx),
});
}
// Created and deleted before it ever synced: store the tombstone, no audit, no push. It is validated like a
// create so the row satisfies every rule of the schema (SPEC §14.4.4 step 7 asks for the references).
let checked = match K::check(conn, &pushed, ctx.users, ctx.now).await {
Ok(c) => c,
Err(Fail::Reject(r)) => return Ok(reject(r, None)),
Err(Fail::Internal(e)) => return Err(e),
};
let version = db::next_server_version(conn).await?;
K::insert(conn, &checked, ctx.caller, version, &ctx.now_ts).await?;
let after = reload::<K>(conn, env.entity_id).await?;
return Ok(Processed {
result: accepted(env, K::entity(after)),
push: None,
});
}
// 8. Validate.
let checked = match K::check(conn, &pushed, ctx.users, ctx.now).await {
Ok(c) => c,
Err(Fail::Reject(r)) => return Ok(reject(r, stored.map(K::entity))),
Err(Fail::Internal(e)) => return Err(e),
};
let Some(stored) = stored else {
// 9. No stored row: insert.
let version = db::next_server_version(conn).await?;
K::insert(conn, &checked, ctx.caller, version, &ctx.now_ts).await?;
let after = reload::<K>(conn, env.entity_id).await?;
audit::<K>(
conn,
ctx,
env,
AuditAction::Create,
K::updated_at(&checked),
None,
&after,
)
.await?;
return Ok(Processed {
result: accepted(env, K::entity(after)),
push: push_of(K::CREATED, env, ctx),
});
};
// 10. Stale: last write wins on the client updated_at; ties go to the server's copy.
let stored_updated = parse_ts(K::stored_updated_at(&stored))
.ok_or_else(|| AppError::internal("stored updated_at does not parse"))?;
if updated_at <= stored_updated {
return Ok(reject(
Rejection::new(RejectionReason::Stale, "a newer version exists"),
Some(K::entity(stored)),
));
}
// 11. Newer: an empty diff changes nothing.
let stored_json = json(&stored)?;
let diff = crate::audit::diff::changes(
K::ENTITY,
AuditAction::Update,
Some(&stored_json),
&json(&checked)?,
);
if diff.is_empty() {
return Ok(Processed {
result: accepted(env, K::entity(stored)),
push: None,
});
}
let version = db::next_server_version(conn).await?;
K::update(conn, &checked, ctx.caller, version).await?;
let after = reload::<K>(conn, env.entity_id).await?;
audit::<K>(
conn,
ctx,
env,
AuditAction::Update,
K::updated_at(&checked),
Some(&stored),
&after,
)
.await?;
Ok(Processed {
result: accepted(env, K::entity(after)),
push: push_of(K::UPDATED, env, ctx),
})
}
/// The pushes of one request after commit: each change, or one `sync_batch` for more than three (AC-13.4).
pub fn batch_pushes(pushes: Vec<PushMessage>, pusher: Uuid) -> Vec<PushMessage> {
if pushes.len() > SYNC_BATCH_THRESHOLD {
vec![PushMessage {
push_type: PushType::SyncBatch,
entity_id: None,
actor_user_id: pusher,
count: u32::try_from(pushes.len()).unwrap_or(u32::MAX),
}]
} else {
pushes
}
}
/// Processes one `POST /sync` body (`mutations` already split into raw JSON values).
pub async fn push(
state: &AppState,
caller: &AuthDevice,
mutations: Vec<Value>,
) -> Result<PushResponse, AppError> {
if mutations.is_empty() || mutations.len() > MAX_MUTATIONS {
return Err(AppError::new(
ErrorCode::BatchTooLarge,
"a push carries 1 to 100 mutations",
)
.with_field("mutations"));
}
let envelopes = mutations
.into_iter()
.enumerate()
.map(|(i, raw)| envelope(i, raw))
.collect::<Result<Vec<_>, _>>()?;
let mut tx = db::begin_immediate(&state.db).await?;
// 2. Household (the whole request, before any mutation).
let users = rows::complete_household(&mut tx).await?;
let now = state.clock.now();
let ctx = Ctx {
state,
caller,
users: &users,
now,
now_ts: format_ts(now),
};
let mut results = Vec::with_capacity(envelopes.len());
let mut pushes = Vec::new();
for env in &envelopes {
// 1. Replay.
let stored: Option<String> =
sqlx::query_scalar("SELECT result FROM sync_mutations WHERE mutation_id = ?")
.bind(env.mutation_id.to_string())
.fetch_optional(&mut *tx)
.await?;
if let Some(stored) = stored {
let value: Value = serde_json::from_str(&stored)
.map_err(|e| AppError::internal(format!("stored sync result: {e}")))?;
results.push(value);
continue;
}
let mut savepoint = tx.begin().await?;
let processed = match env.entity_type {
EntityType::Expense => process::<ExpenseKind>(&mut savepoint, &ctx, env).await?,
EntityType::Settlement => process::<SettlementKind>(&mut savepoint, &ctx, env).await?,
};
match processed.result {
MutationResult::Accepted(_) => savepoint.commit().await?,
MutationResult::Rejected(_) => savepoint.rollback().await?,
}
let result = json(&processed.result)?;
// 12. Record the result, accepted or rejected.
sqlx::query(
"INSERT INTO sync_mutations (mutation_id, device_id, entity_type, entity_id, result, received_at) \
VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(env.mutation_id.to_string())
.bind(caller.device_id.to_string())
.bind(env.entity_type.as_str())
.bind(env.entity_id.to_string())
.bind(result.to_string())
.bind(&ctx.now_ts)
.execute(&mut *tx)
.await?;
results.push(result);
pushes.extend(processed.push);
}
tx.commit().await?;
for message in batch_pushes(pushes, caller.user_id) {
state.push.enqueue(message);
}
Ok(PushResponse {
server_time: ctx.now_ts,
results,
})
}
+160
View File
@@ -0,0 +1,160 @@
//! Loading the rows sync needs that `api::rows` does not cover: settlements, audit entries and the epoch.
use sqlx::{FromRow, SqliteConnection};
use uuid::Uuid;
use crate::api::rows::uuid;
use crate::error::AppError;
use crate::wire::{
Allocation, AuditAction, AuditChange, AuditEntry, AuditSource, EntityType, Settlement,
};
#[derive(FromRow)]
struct SettlementRow {
id: String,
from_user_id: String,
to_user_id: String,
amount_ore: i64,
settlement_date: String,
note: String,
created_by_user_id: String,
updated_by_user_id: String,
created_at: String,
updated_at: String,
deleted_at: Option<String>,
server_version: i64,
}
/// One settlement (tombstones included), with its allocations sorted by `account_id`.
pub async fn settlement(
conn: &mut SqliteConnection,
id: Uuid,
) -> Result<Option<Settlement>, AppError> {
let Some(row) = sqlx::query_as::<_, SettlementRow>(
"SELECT id, from_user_id, to_user_id, amount_ore, settlement_date, note, created_by_user_id, \
updated_by_user_id, created_at, updated_at, deleted_at, server_version FROM settlements WHERE id = ?",
)
.bind(id.to_string())
.fetch_optional(&mut *conn)
.await?
else {
return Ok(None);
};
let allocations: Vec<(String, i64)> = sqlx::query_as(
"SELECT account_id, amount_ore FROM settlement_allocations WHERE settlement_id = ? ORDER BY account_id",
)
.bind(id.to_string())
.fetch_all(&mut *conn)
.await?;
let mut settlement = Settlement {
id: uuid(&row.id)?,
from_user_id: uuid(&row.from_user_id)?,
to_user_id: uuid(&row.to_user_id)?,
amount_ore: row.amount_ore,
settlement_date: row.settlement_date,
note: row.note,
allocations: allocations
.into_iter()
.map(|(account_id, amount_ore)| {
Ok(Allocation {
account_id: uuid(&account_id)?,
amount_ore,
})
})
.collect::<Result<_, AppError>>()?,
created_by_user_id: uuid(&row.created_by_user_id)?,
updated_by_user_id: uuid(&row.updated_by_user_id)?,
created_at: row.created_at,
updated_at: row.updated_at,
deleted_at: row.deleted_at,
server_version: row.server_version,
};
settlement.sort();
Ok(Some(settlement))
}
#[derive(FromRow)]
struct AuditRow {
id: String,
entity_type: String,
entity_id: String,
action: String,
source: String,
actor_user_id: String,
client_at: String,
server_at: String,
changes: String,
server_version: i64,
}
fn entity_type(text: &str) -> Result<EntityType, AppError> {
match text {
"expense" => Ok(EntityType::Expense),
"settlement" => Ok(EntityType::Settlement),
other => Err(AppError::internal(format!(
"unknown audit entity_type {other:?}"
))),
}
}
fn action(text: &str) -> Result<AuditAction, AppError> {
match text {
"create" => Ok(AuditAction::Create),
"update" => Ok(AuditAction::Update),
"delete" => Ok(AuditAction::Delete),
other => Err(AppError::internal(format!(
"unknown audit action {other:?}"
))),
}
}
fn source(text: &str) -> Result<AuditSource, AppError> {
match text {
"app" => Ok(AuditSource::App),
"recurring" => Ok(AuditSource::Recurring),
"import" => Ok(AuditSource::Import),
other => Err(AppError::internal(format!(
"unknown audit source {other:?}"
))),
}
}
/// One audit entry in its wire form (SPEC §14.3.3; `actor_device_id` is not on the wire).
pub async fn audit_entry(
conn: &mut SqliteConnection,
id: Uuid,
) -> Result<Option<AuditEntry>, AppError> {
let Some(row) = sqlx::query_as::<_, AuditRow>(
"SELECT id, entity_type, entity_id, action, source, actor_user_id, client_at, server_at, changes, \
server_version FROM audit_log WHERE id = ?",
)
.bind(id.to_string())
.fetch_optional(&mut *conn)
.await?
else {
return Ok(None);
};
let changes: Vec<AuditChange> = serde_json::from_str(&row.changes)
.map_err(|e| AppError::internal(format!("stored audit changes: {e}")))?;
Ok(Some(AuditEntry {
id: uuid(&row.id)?,
entity_type: entity_type(&row.entity_type)?,
entity_id: uuid(&row.entity_id)?,
action: action(&row.action)?,
source: source(&row.source)?,
actor_user_id: uuid(&row.actor_user_id)?,
client_at: row.client_at,
server_at: row.server_at,
changes,
server_version: row.server_version,
}))
}
/// `meta.sync_epoch`.
pub async fn epoch(conn: &mut SqliteConnection) -> Result<String, AppError> {
let value: Option<String> =
sqlx::query_scalar("SELECT value FROM meta WHERE key = 'sync_epoch'")
.fetch_optional(&mut *conn)
.await?;
value.ok_or_else(|| AppError::internal("meta.sync_epoch is missing"))
}