Files
BalanceWatch/BalanceWatch/Models/SyncTombstone.swift
T
wtandClaude Opus 5.5 0ccb7e1861 Implement contract v2 on the watch: accounts, rates, base currency, ledgers
- Sync and store accounts, transfers, rates and the baseCurrency setting;
  keep recurring, goal, debt and attachment records as raw payloads.
- Skip unknown entities on pull; preserve unknown payload fields of
  transactions, categories and budgets and send them back on re-save.
- Compute balances in integer cents: total balance, analytics and budgets
  in the base currency, per-account balances in the account currency,
  "нет курса C→B" warning when a rate is missing.
- Read-only accounts screen and an optional account picker when adding a
  transaction (currency follows the account).
- Shared ledgers: choose personal or shared in settings, join by code,
  separate SwiftData store, cursor and deletion journal per ledger,
  X-Balance-Ledger header on sync.
- Hide v2 features and do not push new entities when
  GET /v1/capabilities returns 404.
- Keep the wire id of records (lowercase UUID for new ones) so edits of
  records created by other clients do not create duplicates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 23:30:01 +07:00

111 lines
3.8 KiB
Swift

import Foundation
import SwiftData
@Model
final class SyncTombstone {
var id: UUID = UUID()
var entity: String = ""
var recordID: String = ""
var updatedAt: Date = Date.now
init(entity: String, recordID: String, updatedAt: Date = .now) {
self.entity = entity
self.recordID = recordID
self.updatedAt = updatedAt
}
}
struct SyncDeletionRecord: Codable, Identifiable {
let id: UUID
let entity: String
let recordID: String
let updatedAt: Date
init(entity: String, recordID: String, updatedAt: Date = .now) {
self.id = UUID()
self.entity = entity
self.recordID = recordID
self.updatedAt = updatedAt
}
}
enum SyncDeletionStore {
// Журнал свой у каждого пространства (§7): у личного — прежний ключ.
private static var storageKey: String { "balance.server.deletionJournal.v1" + LedgerSelection.keySuffix }
private static var quarantineKey: String { "balance.server.deletionJournal.corrupt" + LedgerSelection.keySuffix }
private static let lock = NSLock()
static func add(entity: String, recordID: String, updatedAt: Date = .now) {
guard supportedEntities.contains(entity), UUID(uuidString:recordID) != nil else { return }
lock.lock()
defer { lock.unlock() }
var records = loadUnlocked()
records.removeAll { $0.entity == entity && $0.recordID == recordID }
records.append(SyncDeletionRecord(entity:entity, recordID:recordID, updatedAt:updatedAt))
saveUnlocked(records)
}
static func page(through cutoff: Date, offset: Int, limit: Int) -> [SyncDeletionRecord] {
guard offset >= 0, limit > 0 else { return [] }
lock.lock()
defer { lock.unlock() }
let records = loadUnlocked()
.filter { $0.updatedAt <= cutoff && supportedEntities.contains($0.entity) && UUID(uuidString:$0.recordID) != nil }
.sorted { $0.updatedAt < $1.updatedAt }
guard offset < records.count else { return [] }
return Array(records[offset..<min(offset + limit, records.count)])
}
static func remove(entity: String, recordID: String, through updatedAt: Date) {
lock.lock()
defer { lock.unlock() }
var records = loadUnlocked()
records.removeAll {
$0.entity == entity && $0.recordID == recordID && $0.updatedAt <= updatedAt
}
saveUnlocked(records)
}
private static let supportedEntities = Set(["transaction", "category", "budget"])
private static func loadUnlocked() -> [SyncDeletionRecord] {
let defaults = UserDefaults.standard
guard let data = defaults.data(forKey:storageKey) else { return [] }
do {
return try JSONDecoder().decode([SyncDeletionRecord].self, from:data)
} catch {
defaults.set(data, forKey:quarantineKey)
defaults.removeObject(forKey:storageKey)
return []
}
}
private static func saveUnlocked(_ records: [SyncDeletionRecord]) {
let defaults = UserDefaults.standard
guard !records.isEmpty else {
defaults.removeObject(forKey:storageKey)
return
}
if let data = try? JSONEncoder().encode(records) {
defaults.set(data, forKey:storageKey)
}
}
}
extension ModelContext {
func deleteForSync(_ transaction: FinanceTransaction) {
SyncDeletionStore.add(entity:"transaction", recordID:transaction.wireID)
delete(transaction)
}
func deleteForSync(_ category: CustomCategory) {
SyncDeletionStore.add(entity:"category", recordID:category.wireID)
delete(category)
}
func deleteForSync(_ budget: MonthlyBudget) {
SyncDeletionStore.add(entity:"budget", recordID:budget.wireID)
delete(budget)
}
}