Server overhaul: one place for stored-row reads and the shared inserts

api::rows now also loads settlements, audit entries and the epoch (sync::rows is gone; audit enums parse through
rows::token) and owns the expense, split, settlement and allocation inserts that sync push, recurring generation and
the importer each had their own copy of (rows::Origin carries the server-owned columns). accounts::insert_account and
replace_shares are shared with the importer. Recurring generation moves the per-period expense into
create_period_expense. No SQL, wire or log change.

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-18 22:28:51 +02:00
parent a0259ab68d
commit 8103dae2be
8 changed files with 426 additions and 453 deletions
@@ -5,7 +5,6 @@
pub mod check;
pub mod pull;
pub mod push;
pub mod rows;
use chrono::{DateTime, Duration, Utc};
+3 -5
View File
@@ -7,8 +7,6 @@ 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).
@@ -47,7 +45,7 @@ pub async fn pull(state: &AppState, since: i64, limit: i64) -> Result<SyncPullRe
)
.with_field("since"));
}
let epoch = sync_rows::epoch(&mut tx).await?;
let epoch = 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)
@@ -103,12 +101,12 @@ pub async fn pull(state: &AppState, since: i64, limit: i64) -> Result<SyncPullRe
.ok_or_else(|| missing("expenses"))?,
),
"settlements" => page.settlements.push(
sync_rows::settlement(&mut tx, id)
rows::settlement(&mut tx, id)
.await?
.ok_or_else(|| missing("settlements"))?,
),
"audit_log" => page.audit_entries.push(
sync_rows::audit_entry(&mut tx, id)
rows::audit_entry(&mut tx, id)
.await?
.ok_or_else(|| missing("audit_log"))?,
),
+14 -82
View File
@@ -12,7 +12,7 @@ use sqlx::{Acquire, SqliteConnection};
use uuid::Uuid;
use crate::AppState;
use crate::api::rows;
use crate::api::rows::{self, Origin};
use crate::audit::AuditEvent;
use crate::auth::AuthDevice;
use crate::db;
@@ -26,7 +26,6 @@ use crate::wire::{
};
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;
@@ -241,32 +240,7 @@ impl Kind for ExpenseKind {
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
rows::insert_expense(conn, p, None, origin(caller, version, now_ts)).await
}
async fn update(
@@ -299,23 +273,19 @@ impl Kind for ExpenseKind {
.bind(p.id.to_string())
.execute(&mut *conn)
.await?;
insert_splits(conn, p).await
rows::insert_expense_splits(conn, p.id, &p.splits).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?;
/// The server-owned columns of a row the caller pushes.
fn origin<'a>(caller: &AuthDevice, version: i64, now_ts: &'a str) -> Origin<'a> {
Origin {
user_id: caller.user_id,
device_id: Some(caller.device_id),
import_key: None,
received_at: now_ts,
server_version: version,
}
Ok(())
}
impl Kind for SettlementKind {
@@ -345,7 +315,7 @@ impl Kind for SettlementKind {
}
async fn load(conn: &mut SqliteConnection, id: Uuid) -> Result<Option<Settlement>, AppError> {
sync_rows::settlement(conn, id).await
rows::settlement(conn, id).await
}
async fn check(
@@ -364,28 +334,7 @@ impl Kind for SettlementKind {
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
rows::insert_settlement(conn, p, origin(caller, version, now_ts)).await
}
async fn update(
@@ -415,27 +364,10 @@ impl Kind for SettlementKind {
.bind(p.id.to_string())
.execute(&mut *conn)
.await?;
insert_allocations(conn, p).await
rows::insert_allocations(conn, p.id, &p.allocations).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,
-160
View File
@@ -1,160 +0,0 @@
//! 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"))
}