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:
@@ -5,7 +5,6 @@
|
||||
pub mod check;
|
||||
pub mod pull;
|
||||
pub mod push;
|
||||
pub mod rows;
|
||||
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
|
||||
|
||||
@@ -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"))?,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
Reference in New Issue
Block a user