M2a: server workspace, kvitt-domain, skeleton routes, OpenAPI match, Dockerfile
Cargo workspace (kvitt-domain, kvitt-server, kvitt-import), Rust 1.99.1 pinned. kvitt-domain passes every Rust vector and adds next_occurrence_date (D-015). kvitt-server: config, SQLite pool (WAL, §8.9 pragmas), migration 0001 identical to the contract schema, error type for all 25 codes, auth extractor and protocol layer, all 33 routes (E-1/E-2 implemented, the rest 501), utoipa document equal to contracts/openapi.yaml, seams for M3/M4/M9, serve/migrate/healthcheck. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
//! Sync protocol objects (SPEC §14.4.2, §14.4.3, §14.4.6).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::account::Account;
|
||||
use super::audit::{AuditEntry, EntityType};
|
||||
use super::category::Category;
|
||||
use super::expense::{Expense, Split, SplitMode};
|
||||
use super::recurring::RecurringTemplate;
|
||||
use super::settlement::{Allocation, Settlement};
|
||||
use super::user::User;
|
||||
use super::{DateSchema, OreSchema, ServerVersionSchema, TimestampSchema, UuidSchema};
|
||||
|
||||
/// E-31 `GET /sync` response: one page; every array present (possibly empty) and sorted by `server_version`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct SyncPullResponse {
|
||||
/// `meta.sync_epoch`, 32 lowercase hex characters.
|
||||
#[schema(pattern = "^[0-9a-f]{32}$")]
|
||||
pub epoch: String,
|
||||
pub since: i64,
|
||||
/// The highest `server_version` in the page, or `since` when the page is empty.
|
||||
pub next_since: i64,
|
||||
pub has_more: bool,
|
||||
#[schema(value_type = TimestampSchema)]
|
||||
pub server_time: String,
|
||||
pub users: Vec<User>,
|
||||
pub categories: Vec<Category>,
|
||||
pub accounts: Vec<Account>,
|
||||
pub recurring_templates: Vec<RecurringTemplate>,
|
||||
pub expenses: Vec<Expense>,
|
||||
pub settlements: Vec<Settlement>,
|
||||
pub audit_entries: Vec<AuditEntry>,
|
||||
}
|
||||
|
||||
/// Exactly the client-writable fields of an expense (SPEC §14.4.3); server-owned fields are ignored if sent. A
|
||||
/// delete is the full entity with `deleted_at` set.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct PushedExpense {
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub id: Uuid,
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub account_id: Uuid,
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub category_id: Uuid,
|
||||
#[schema(value_type = OreSchema)]
|
||||
pub amount_ore: i64,
|
||||
pub description: String,
|
||||
pub notes: String,
|
||||
#[schema(value_type = DateSchema)]
|
||||
pub expense_date: String,
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub payer_user_id: Uuid,
|
||||
pub split_mode: SplitMode,
|
||||
pub splits: Vec<Split>,
|
||||
#[schema(value_type = TimestampSchema)]
|
||||
pub created_at: String,
|
||||
#[schema(value_type = TimestampSchema)]
|
||||
pub updated_at: String,
|
||||
#[serde(deserialize_with = "super::required_nullable")]
|
||||
#[schema(value_type = Option<TimestampSchema>, required = true)]
|
||||
pub deleted_at: Option<String>,
|
||||
}
|
||||
|
||||
/// Exactly the client-writable fields of a settlement (SPEC §14.4.3).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct PushedSettlement {
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub id: Uuid,
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub from_user_id: Uuid,
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub to_user_id: Uuid,
|
||||
#[schema(value_type = OreSchema)]
|
||||
pub amount_ore: i64,
|
||||
#[schema(value_type = DateSchema)]
|
||||
pub settlement_date: String,
|
||||
pub note: String,
|
||||
pub allocations: Vec<Allocation>,
|
||||
#[schema(value_type = TimestampSchema)]
|
||||
pub created_at: String,
|
||||
#[schema(value_type = TimestampSchema)]
|
||||
pub updated_at: String,
|
||||
#[serde(deserialize_with = "super::required_nullable")]
|
||||
#[schema(value_type = Option<TimestampSchema>, required = true)]
|
||||
pub deleted_at: Option<String>,
|
||||
}
|
||||
|
||||
/// The constant `"expense"` (the discriminator value of [`ExpenseMutation`]).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub enum ExpenseTag {
|
||||
#[serde(rename = "expense")]
|
||||
Expense,
|
||||
}
|
||||
|
||||
/// The constant `"settlement"` (the discriminator value of [`SettlementMutation`]).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub enum SettlementTag {
|
||||
#[serde(rename = "settlement")]
|
||||
Settlement,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ExpenseMutation {
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub mutation_id: Uuid,
|
||||
#[schema(inline)]
|
||||
pub entity_type: ExpenseTag,
|
||||
pub entity: PushedExpense,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct SettlementMutation {
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub mutation_id: Uuid,
|
||||
#[schema(inline)]
|
||||
pub entity_type: SettlementTag,
|
||||
pub entity: PushedSettlement,
|
||||
}
|
||||
|
||||
/// One pushed change, discriminated by `entity_type`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(untagged)]
|
||||
#[schema(discriminator(property_name = "entity_type", mapping(
|
||||
("expense" = "#/components/schemas/ExpenseMutation"),
|
||||
("settlement" = "#/components/schemas/SettlementMutation"),
|
||||
)))]
|
||||
pub enum Mutation {
|
||||
Expense(ExpenseMutation),
|
||||
Settlement(SettlementMutation),
|
||||
}
|
||||
|
||||
impl Mutation {
|
||||
pub fn mutation_id(&self) -> Uuid {
|
||||
match self {
|
||||
Self::Expense(m) => m.mutation_id,
|
||||
Self::Settlement(m) => m.mutation_id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn entity_type(&self) -> EntityType {
|
||||
match self {
|
||||
Self::Expense(_) => EntityType::Expense,
|
||||
Self::Settlement(_) => EntityType::Settlement,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// E-32 `POST /sync` request: 1 to 100 mutations (more is 422 `batch_too_large`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct SyncPushRequest {
|
||||
pub mutations: Vec<Mutation>,
|
||||
}
|
||||
|
||||
/// A full wire object of the type named by `entity_type`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum SyncEntity {
|
||||
Expense(Expense),
|
||||
Settlement(Settlement),
|
||||
}
|
||||
|
||||
/// The constant `"accepted"`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub enum AcceptedTag {
|
||||
#[serde(rename = "accepted")]
|
||||
Accepted,
|
||||
}
|
||||
|
||||
/// The constant `"rejected"`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub enum RejectedTag {
|
||||
#[serde(rename = "rejected")]
|
||||
Rejected,
|
||||
}
|
||||
|
||||
/// Exhaustive (SPEC §14.4.6).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RejectionReason {
|
||||
Stale,
|
||||
Deleted,
|
||||
ValidationFailed,
|
||||
ReferenceNotFound,
|
||||
ClockSkew,
|
||||
}
|
||||
|
||||
/// An accepted mutation. Accepted and rejected results have different key sets (SPEC §14.4.3).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct AcceptedResult {
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub mutation_id: Uuid,
|
||||
pub entity_type: EntityType,
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub entity_id: Uuid,
|
||||
#[schema(inline)]
|
||||
pub status: AcceptedTag,
|
||||
#[schema(value_type = ServerVersionSchema)]
|
||||
pub server_version: i64,
|
||||
// The full wire object after processing. (A doc comment here would make utoipa wrap the schema in `allOf`.)
|
||||
#[schema(inline)]
|
||||
pub entity: SyncEntity,
|
||||
}
|
||||
|
||||
/// A rejected mutation (SPEC §14.4.6).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct RejectedResult {
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub mutation_id: Uuid,
|
||||
pub entity_type: EntityType,
|
||||
#[schema(value_type = UuidSchema)]
|
||||
pub entity_id: Uuid,
|
||||
#[schema(inline)]
|
||||
pub status: RejectedTag,
|
||||
pub reason: RejectionReason,
|
||||
/// For validation failures, the domain error token of SPEC §14.5.3.
|
||||
pub message: String,
|
||||
// The stored row, or null when the server has no row with that id.
|
||||
#[schema(inline, required = true)]
|
||||
pub current: Option<SyncEntity>,
|
||||
}
|
||||
|
||||
/// One result per mutation, discriminated by `status`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(untagged)]
|
||||
#[schema(discriminator(property_name = "status", mapping(
|
||||
("accepted" = "#/components/schemas/AcceptedResult"),
|
||||
("rejected" = "#/components/schemas/RejectedResult"),
|
||||
)))]
|
||||
pub enum MutationResult {
|
||||
Accepted(AcceptedResult),
|
||||
Rejected(RejectedResult),
|
||||
}
|
||||
|
||||
/// E-32 response: one result per mutation, in request order.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
pub struct SyncPushResponse {
|
||||
#[schema(value_type = TimestampSchema)]
|
||||
pub server_time: String,
|
||||
pub results: Vec<MutationResult>,
|
||||
}
|
||||
Reference in New Issue
Block a user