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:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
Reference in New Issue
Block a user