【GitHub】Actual Budget 技术深度解析:本地优先架构与CRDT同步引擎


一、项目介绍

1.1 什么是 Actual Budget?

Actual Budget 是一款革命性的本地优先(Local-First) 个人财务管理应用。与传统的云同步理财工具不同,Actual 将用户数据的主权完全交还给用户:

  • 100% 开源 - MIT License,代码完全透明
  • 本地优先 - 数据首先存储在本地设备
  • 隐私优先 - 可选同步,用户掌控数据
  • 跨平台 - Web、Windows、Mac、Linux 全平台支持

1.2 为什么值得关注?

在 YNAB(You Need A Budget)等商业软件月费高昂、数据隐私堪忧的背景下,Actual Budget 提供了:

  1. 零成本 - 完全免费开源
  2. 零锁定 - 数据本地存储,随时导出
  3. 零妥协 - 功能不输商业软件

1.3 项目数据概览

指标 数据
GitHub Stars 18.5k+ ⭐
首次开源 2022年4月
总提交数 5,200+ commits
活跃度 🔥 非常活跃(2026年6月仍在高频更新)
技术栈 TypeScript + React + Electron + Node.js
架构模式 Monorepo (Yarn Workspaces)

二、核心原理

2.1 本地优先架构(Local-First Architecture)

2.1.1 什么是本地优先?

本地优先是一种应用架构理念,由 Ink & Switch 实验室提出,核心原则是:

"Your data should primarily live on your device, not in the cloud."

传统云应用 vs 本地优先应用对比:

复制代码
┌─────────────────────────────────────────────────────────┐
│                  传统云应用架构                          │
├─────────────────────────────────────────────────────────┤
│  用户操作 → 发送到云端 → 云端处理 → 返回结果          │
│  ❌ 离线无法使用                                       │
│  ❌ 云端故障导致服务中断                               │
│  ❌ 数据隐私依赖服务商                                 │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│                  本地优先应用架构                        │
├─────────────────────────────────────────────────────────┤
│  用户操作 → 本地立即响应 → 后台同步到云端              │
│  ✅ 离线完全可用                                       │
│  ✅ 云端故障不影响本地使用                             │
│  ✅ 用户掌控数据主权                                   │
└─────────────────────────────────────────────────────────┘
2.1.2 Actual 的本地优先实现

Actual 使用 SQLite 作为本地数据库,所有操作首先在本地完成,然后通过 CRDT 系统进行增量同步。

typescript 复制代码
// 数据流向示例
用户界面操作
    ↓
loot-core 处理业务逻辑
    ↓
SQLite 本地存储 (立即完成)
    ↓
CRDT 生成同步消息 (后台)
    ↓
同步服务器 (可选)

2.2 CRDT:无冲突复制数据类型

2.2.1 CRDT 基本概念

CRDT (Conflict-free Replicated Data Type) 是一种数据结构,它保证:

  1. 并发安全性 - 多个节点可以同时修改数据
  2. 自动冲突解决 - 不需要中央协调器
  3. 最终一致性 - 所有节点最终会达到相同状态
2.2.2 Actual 的 CRDT 实现

Actual 使用基于消息的 CRDT,每次数据库修改都会生成一个带时间戳的消息,这些消息可以在不同设备间安全重放。

核心组件:

组件 作用
Merkle Clock 混合逻辑时钟,为每条消息分配全局有序的时间戳
Protobuf 消息序列化格式,高效压缩同步数据
LWW (Last-Write-Wins) 冲突解决策略,最后写入的值获胜
Tombstone 删除标记,确保删除操作能正确同步

三、核心公式与算法

3.1 Merkle Clock(混合逻辑时钟)

3.1.1 时间戳结构

Actual 使用 Merkle Clock 为每个操作分配时间戳,确保全局因果顺序。

时间戳组成:

复制代码
Timestamp = (counter, nodeId)

其中:

  • counter - 逻辑计数器,单调递增
  • nodeId - 节点唯一标识符(UUID)
3.1.2 时间戳比较算法
typescript 复制代码
function compareTimestamp(ts1: Timestamp, ts2: Timestamp): number {
  // 第一优先级:比较计数器
  if (ts1.counter > ts2.counter) return 1;
  if (ts1.counter < ts2.counter) return -1;
  
  // 第二优先级:计数器相同时,比较节点ID(字典序)
  if (ts1.nodeId > ts2.nodeId) return 1;
  if (ts1.nodeId < ts2.nodeId) return -1;
  
  // 完全相同
  return 0;
}

因果一致性保证:

复制代码
如果操作 A 在操作 B 之前发生(在同一个节点上),
那么 A 的时间戳一定小于 B 的时间戳。

形式化表达:
A → B (因果关系的 happens-before)
⟹ Timestamp(A) < Timestamp(B)

3.2 冲突解决:Last-Write-Wins (LWW)

3.2.1 LWW 算法

当多个设备同时修改同一字段时,使用 LWW 策略解决冲突:

typescript 复制代码
function resolveConflict(
  localValue: Value,
  remoteValue: Value,
  localTimestamp: Timestamp,
  remoteTimestamp: Timestamp
): Value {
  // 比较时间戳,较大的获胜
  if (compareTimestamp(remoteTimestamp, localTimestamp) > 0) {
    return remoteValue;  // 远程值较新
  } else {
    return localValue;   // 本地值较新或相等
  }
}
3.2.2 字段级合并

Actual 的冲突解决是字段级别的,而不是行级别:

复制代码
场景:两个设备同时修改同一笔交易的不同字段

设备A: 修改 amount = 50
设备B: 修改 notes = "Groceries"

结果: amount = 50, notes = "Groceries" (两个修改都保留)

3.3 增量同步算法

3.3.1 同步消息格式

每条 CRDT 消息包含:

protobuf 复制代码
message CRDTMessage {
  string table = 1;        // 表名
  string row = 2;           // 行ID
  string column = 3;        // 列名
  string value = 4;         // 新值
  Timestamp timestamp = 5;   // 时间戳
  bool isDeleted = 6;       // 是否删除(tombstone)
}
3.3.2 增量同步公式
复制代码
设:
- T_last = 上次同步的时间戳
- M_all = 所有消息集合
- M_new = {m ∈ M_all | m.timestamp > T_last}

同步过程:
1. 客户端上传 M_new (增量消息)
2. 服务器合并消息到全局状态
3. 服务器返回 M_server_new (客户端缺失的消息)
4. 客户端应用 M_server_new

四、架构图示

4.1 项目整体架构

复制代码
┌─────────────────────────────────────────────────────────────┐
│                     用户界面层                               │
│  ┌──────────────┐    ┌──────────────┐                    │
│  │desktop-client│    │desktop-electron│                    │
│  │  (React UI)  │    │  (Electron)   │                    │
│  └──────┬───────┘    └──────┬───────┘                    │
│         │                    │                              │
│         └──────────┬─────────┘                            │
│                    ↓                                      │
│         ┌──────────────────┐                              │
│         │component-library │                              │
│         │  (共享组件库)    │                              │
│         └──────────────────┘                              │
└─────────────────────────────────────────────────────────────┘
                          ↓
┌─────────────────────────────────────────────────────────────┐
│                     业务逻辑层                               │
│  ┌────────────────────────────────────────────┐            │
│  │            loot-core                       │            │
│  │  ┌──────────┐  ┌──────────┐  ┌────────┐ │            │
│  │  │  client  │  │  server  │  │ shared │ │            │
│  │  │  (客户端) │  │  (服务端) │  │ (共享) │ │            │
│  │  └──────────┘  └──────────┘  └────────┘ │            │
│  │            ↓         ↓                     │            │
│  │      数据库操作    CRDT消息生成             │            │
│  └────────────────────────────────────────────┘            │
└─────────────────────────────────────────────────────────────┘
                          ↓
┌─────────────────────────────────────────────────────────────┐
│                      数据同步层                              │
│  ┌────────────┐         ┌─────────────┐                  │
│  │    crdt     │◄──────►│ sync-server │                  │
│  │ (冲突解决)   │         │  (同步服务) │                  │
│  └────────────┘         └─────────────┘                  │
│       ↓                      ↓                            │
│  Protocol Buffer        Express Server                    │
│  (消息序列化)             (Node.js)                    │
└─────────────────────────────────────────────────────────────┘
                          ↓
┌─────────────────────────────────────────────────────────────┐
│                      数据存储层                              │
│  ┌─────────────────────────────────┐                      │
│  │    better-sqlite3 (SQLite)      │                      │
│  │  ┌──────────┐ ┌──────────────┐ │                      │
│  │  │messages   │ │messages_clock│ │                      │
│  │  │(CRDT消息) │ │  (时钟状态)   │ │                      │
│  │  └──────────┘ └──────────────┘ │                      │
│  └─────────────────────────────────┘                      │
└─────────────────────────────────────────────────────────────┘

4.2 CRDT 系统数据流

复制代码
┌─────────────┐
│  用户操作    │
│ (新增交易)  │
└──────┬──────┘
       │
       ↓
┌─────────────────────────────────────────┐
│         loot-core (业务逻辑)             │
│  ┌─────────────────────────────────┐   │
│  │ 1. 生成 Merkle Clock 时间戳     │   │
│  │ 2. 创建 CRDT 消息               │   │
│  │ 3. 写入本地 SQLite              │   │
│  │ 4. 序列化消息 (Protobuf)        │   │
│  └─────────────────────────────────┘   │
└──────┬──────────────────────────────────┘
       │
       ├─────────→ 本地立即完成 (用户体验)
       │
       ↓
┌─────────────────────────────────────────┐
│      sync-server (后台同步)              │
│  ┌─────────────────────────────────┐   │
│  │ 1. 接收增量消息                 │   │
│  │ 2. 按时间戳排序                 │   │
│  │ 3. LWW 冲突解决                │   │
│  │ 4. 合并到全局状态               │   │
│  └─────────────────────────────────┘   │
└──────┬──────────────────────────────────┘
       │
       ↓
┌─────────────────────────────────────────┐
│         其他设备拉取同步                  │
│  ┌─────────────────────────────────┐   │
│  │ 1. 请求增量消息 (since T_last) │   │
│  │ 2. 接收并反序列化               │   │
│  │ 3. 应用 CRDT 消息到本地数据库   │   │
│  │ 4. 更新本地时钟状态             │   │
│  └─────────────────────────────────┘   │
└─────────────────────────────────────────┘

4.3 Monorepo 包依赖关系

复制代码
actual (根项目)
    │
    ├─→ loot-core (核心逻辑)
    │       ↑
    ├─→ desktop-client (Web UI) ◄─┐
    │       │                      │
    │       ↓                      │
    ├─→ desktop-electron (桌面包装) │
    │                              │
    ├─→ sync-server (同步服务器) ──┘ (依赖 @actual-app/web)
    │
    ├─→ crdt (CRDT 引擎)
    │
    ├─→ api (公开 API)
    │
    ├─→ component-library (组件库)
    │
    ├─→ plugins-service (插件服务)
    │
    ├─→ eslint-plugin-actual (Lint 规则)
    │
    ├─→ vite-plugin-peggy (构建插件)
    │
    ├─→ cli (命令行工具)
    │
    ├─→ ci-actions (CI 自动化)
    │
    └─→ docs (文档网站)

五、踩坑点与实战经验

5.1 CRDT 时钟重置问题

问题描述

当一个预算文件从一台设备转移到另一台设备时(例如导出后导入),如果不重置时钟,两台设备会使用相同的 nodeId,导致时间戳冲突。

踩坑场景
复制代码
设备A: nodeId = "abc", counter = 100
    ↓ 导出预算文件
设备B: nodeId = "abc" (相同!), counter = 100 (相同!)

结果:
设备A 生成消息: timestamp = (101, "abc")
设备B 生成消息: timestamp = (101, "abc")  ← 完全相同!

冲突:无法判断哪个消息更新
解决方案

Actual 使用 resetClock 标志来解决这个问题:

typescript 复制代码
// 导出预算时设置 resetClock 标志
function exportBudget() {
  const metadata = {
    // ... 其他元数据
    resetClock: true  // ← 关键标志
  };
  return { data: budgetData, meta: metadata };
}

// 导入预算时检查标志
function loadBudget(data, meta) {
  if (meta.resetClock) {
    // 生成新的客户端ID
    CRDT.resetClock(generateUUID());
  }
  // ... 加载数据
}

源码位置

  • packages/loot-core/src/server/cloud-storage.ts:178-184
  • packages/loot-core/src/server/budgetfiles/app.ts:563-577

5.2 Protobuf 生成的代码与 CSP 冲突

问题描述

使用 protoc 生成的 JavaScript 代码包含一段不兼容 CSP(Content Security Policy)的代码:

javascript 复制代码
// 默认生成的代码
var global = (function() { 
  return this || window || global || self || Function('return this')(); 
})();

这段代码在启用 CSP 的浏览器环境中会报错。

解决方案

需要手动修改生成的代码:

javascript 复制代码
// 修改后的代码
var global = globalThis;

自动化建议

可以在构建脚本中添加自动替换逻辑:

javascript 复制代码
// scripts/fix-protobuf-csp.js
import fs from 'fs';
import path from 'path';

const filePath = path.join(__dirname, '../src/generated/protobuf.js');
let content = fs.readFileSync(filePath, 'utf-8');

content = content.replace(
  /var global = \(function\(\) \{[^}]+\}\)\.call\(null\);/,
  'var global = globalThis;'
);

fs.writeFileSync(filePath, content);

源码位置packages/crdt/README.md

5.3 增量同步的消息丢失

问题描述

在长时间不同步的情况下,增量同步可能丢失消息,因为服务器可能清理了旧消息。

解决方案

Actual 实现了全量同步增量同步两种模式:

typescript 复制代码
async function sync() {
  const lastSyncTimestamp = await getLastSyncTimestamp();
  
  if (lastSyncTimestamp === null) {
    // 首次同步:全量同步
    await initialFullSync();
  } else {
    // 后续同步:增量同步
    await incrementalSync(lastSyncTimestamp);
  }
}

async function incrementalSync(lastSyncTimestamp: number) {
  // 获取自上次同步以来的新消息
  const newMessages = await crdt.getMessagesSince(lastSyncTimestamp);
  
  if (newMessages.length === 0) {
    console.log('Already up to date');
    return;
  }
  
  // 上传本地新消息
  await uploadMessages(newMessages);
  
  // 下载服务器端的新消息
  const remoteMessages = await downloadMessages(lastSyncTimestamp);
  
  // 应用远程消息
  await applyMessages(remoteMessages);
}

源码位置packages/loot-core/src/server/cloud-storage.ts:341-363

5.4 缓存表的不同步问题

问题描述

kvcachekvcache_key 表用于存储计算缓存(例如报表数据),如果同步这些缓存,会导致:

  1. 同步文件过大
  2. 新设备上的缓存可能损坏
  3. stale 的电子表格公式
解决方案

Actual 明确排除缓存表的同步:

typescript 复制代码
// 同步时排除的表
const EXCLUDED_TABLES = ['kvcache', 'kvcache_key'];

function shouldSyncTable(tableName: string): boolean {
  return !EXCLUDED_TABLES.includes(tableName);
}

源码位置packages/loot-core/src/server/cloud-storage.ts:165-170

5.5 React 组件的性能优化

问题描述

在大型预算文件(数千笔交易)的情况下,React 渲染性能会下降。

解决方案

Actual 使用了多种优化策略:

  1. 虚拟滚动(Virtual Scrolling)
typescript 复制代码
// 只渲染可见区域的交易
import { useVirtualizer } from '@tanstack/react-virtual';

function TransactionList({ transactions }) {
  const parentRef = useRef<HTMLDivElement>(null);
  
  const virtualizer = useVirtualizer({
    count: transactions.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 50,  // 每行高度
  });
  
  return (
    <div ref={parentRef} style={{ height: '500px', overflow: 'auto' }}>
      <div style={{ height: virtualizer.getTotalSize() }}>
        {virtualizer.getVirtualItems().map((virtualItem) => (
          <div
            key={virtualItem.key}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              height: virtualItem.size,
              transform: `translateY(${virtualItem.start}px)`,
            }}
          >
            <TransactionRow transaction={transactions[virtualItem.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}
  1. React.memo 避免不必要的渲染
typescript 复制代码
const TransactionRow = React.memo(function TransactionRow({ transaction }) {
  // 只有 transaction 变化时才会重新渲染
  return <div>{transaction.payee} - {transaction.amount}</div>;
});

六、源码拆解

6.1 loot-core:核心业务逻辑

6.1.1 目录结构
复制代码
packages/loot-core/
├── src/
│   ├── client/          # 客户端逻辑
│   │   ├── app.js       # 应用主逻辑
│   │   └── ...
│   ├── server/          # 服务端逻辑
│   │   ├── budgetfiles/ # 预算文件管理
│   │   │   └── app.ts   # 核心应用类
│   │   ├── cloud-storage.ts  # 云存储同步
│   │   └── ...
│   ├── shared/          # 共享工具函数
│   ├── types/           # TypeScript 类型定义
│   └── ...
├── migrations/          # 数据库迁移文件
└── package.json
6.1.2 核心类:App (app.ts)
typescript 复制代码
// packages/loot-core/src/server/budgetfiles/app.ts

export class App {
  // 加载预算文件
  async loadBudget(filePath: string): Promise<void> {
    // 1. 打开 SQLite 数据库
    await this.openDatabase(filePath);
    
    // 2. 加载 CRDT 时钟状态
    await db.loadClock();
    
    // 3. 检查是否需要重置时钟
    const meta = await this.getMetadata();
    if (meta.resetClock) {
      CRDT.resetClock(generateUUID());
      // 清除标志
      await this.updateMetadata({ resetClock: false });
    }
    
    // 4. 应用待处理的 CRDT 消息
    await this.applyPendingMessages();
  }
  
  // 保存预算变更
  async saveTransaction(transaction: Transaction): Promise<void> {
    // 1. 生成 CRDT 时间戳
    const timestamp = CRDT.getClock().tick();
    
    // 2. 写入本地数据库
    await db.run(
      'INSERT OR REPLACE INTO transactions (id, amount, payee, ...) VALUES (?, ?, ?, ...)',
      [transaction.id, transaction.amount, transaction.payee, ...]
    );
    
    // 3. 生成 CRDT 消息
    const message = CRDT.createMessage({
      table: 'transactions',
      row: transaction.id,
      column: '*',  // * 表示整行
      value: JSON.stringify(transaction),
      timestamp: timestamp,
      isDeleted: false
    });
    
    // 4. 存储消息
    await db.run(
      'INSERT INTO messages (timestamp, content) VALUES (?, ?)',
      [CRDT.serializeTimestamp(timestamp), CRDT.serializeMessage(message)]
    );
  }
}

源码位置packages/loot-core/src/server/budgetfiles/app.ts:561-577

6.2 crdt:CRDT 引擎

6.2.1 Merkle Clock 实现
typescript 复制代码
// packages/crdt/src/merkle-clock.ts

export class MerkleClock {
  private counter: number;
  private nodeId: string;
  
  constructor(nodeId: string, initialCounter: number = 0) {
    this.nodeId = nodeId;
    this.counter = initialCounter;
  }
  
  // 生成下一个时间戳
  tick(): Timestamp {
    this.counter++;
    return {
      counter: this.counter,
      nodeId: this.nodeId
    };
  }
  
  // 比较时间戳
  static compare(a: Timestamp, b: Timestamp): number {
    if (a.counter > b.counter) return 1;
    if (a.counter < b.counter) return -1;
    
    // 计数器相同,比较节点ID
    if (a.nodeId > b.nodeId) return 1;
    if (a.nodeId < b.nodeId) return -1;
    
    return 0;  // 完全相同
  }
  
  // 序列化时钟状态(用于持久化)
  serialize(): string {
    return JSON.stringify({
      counter: this.counter,
      nodeId: this.nodeId
    });
  }
  
  // 反序列化时钟状态
  static deserialize(data: string): MerkleClock {
    const { counter, nodeId } = JSON.parse(data);
    return new MerkleClock(nodeId, counter);
  }
}
6.2.2 消息序列化 (Protobuf)
protobuf 复制代码
// packages/crdt/proto/message.proto

syntax = "proto3";

package crdt;

message Timestamp {
  uint64 counter = 1;
  string node_id = 2;
}

message CRDTMessage {
  string table = 1;
  string row = 2;
  string column = 3;
  string value = 4;
  Timestamp timestamp = 5;
  bool is_deleted = 6;
}

message MessageBatch {
  repeated CRDTMessage messages = 1;
}
typescript 复制代码
// packages/crdt/src/serializer.ts

import { CRDTMessage, Timestamp } from './generated/protobuf';

export function serializeMessage(message: CRDTMessage): Uint8Array {
  const protoMessage = CRDTMessage.create({
    table: message.table,
    row: message.row,
    column: message.column,
    value: message.value,
    timestamp: {
      counter: message.timestamp.counter,
      nodeId: message.timestamp.nodeId
    },
    isDeleted: message.isDeleted
  });
  
  return CRDTMessage.encode(protoMessage).finish();
}

export function deserializeMessage(data: Uint8Array): CRDTMessage {
  return CRDTMessage.decode(data);
}

源码位置packages/crdt/package.json, packages/crdt/README.md

6.3 sync-server:同步服务器

6.3.1 同步端点实现
typescript 复制代码
// packages/sync-server/src/routes/sync.ts

import express from 'express';
import { CRDT } from '@actual-app/crdt';

const router = express.Router();

// 上传增量消息
router.post('/api/sync/upload', async (req, res) => {
  const { budgetId, messages } = req.body;
  
  // 1. 验证请求
  if (!budgetId || !messages) {
    return res.status(400).json({ error: 'Invalid request' });
  }
  
  // 2. 解码消息
  const decodedMessages = messages.map((m: string) => 
    CRDT.deserializeMessage(Buffer.from(m, 'base64'))
  );
  
  // 3. 按时间戳排序
  decodedMessages.sort((a, b) => 
    CRDT.compareTimestamps(a.timestamp, b.timestamp)
  );
  
  // 4. 合并到数据库
  for (const message of decodedMessages) {
    await applyMessage(budgetId, message);
  }
  
  res.json({ success: true });
});

// 下载增量消息
router.get('/api/sync/download', async (req, res) => {
  const { budgetId, since } = req.query;
  
  // 1. 查询自 since 以来的新消息
  const messages = await getMessagesSince(budgetId, parseInt(since as string));
  
  // 2. 序列化消息
  const encodedMessages = messages.map(m => 
    Buffer.from(CRDT.serializeMessage(m)).toString('base64')
  );
  
  res.json({
    messages: encodedMessages,
    timestamp: Date.now()
  });
});

async function applyMessage(budgetId: string, message: CRDTMessage) {
  // 1. 检查冲突(同一行同一列的新旧程度)
  const existing = await db.get(
    'SELECT timestamp FROM messages WHERE table = ? AND row = ? AND column = ?',
    [message.table, message.row, message.column]
  );
  
  if (existing) {
    const existingTimestamp = CRDT.deserializeTimestamp(existing.timestamp);
    
    // 2. LWW 冲突解决
    if (CRDT.compareTimestamps(message.timestamp, existingTimestamp) > 0) {
      // 新消息较新,应用它
      await db.run(
        'UPDATE messages SET timestamp = ?, content = ? WHERE ...',
        [CRDT.serializeTimestamp(message.timestamp), CRDT.serializeMessage(message)]
      );
    } else {
      // 旧消息,忽略
      console.log('Ignoring outdated message');
    }
  } else {
    // 3. 无冲突,直接插入
    await db.run(
      'INSERT INTO messages (table, row, column, timestamp, content) VALUES (?, ?, ?, ?, ?)',
      [message.table, message.row, message.column, CRDT.serializeTimestamp(message.timestamp), CRDT.serializeMessage(message)]
    );
  }
}

源码位置packages/sync-server/ (Note: 目前主要是 JavaScript,正在迁移到 TypeScript)

6.4 desktop-client:React 前端

6.4.1 组件结构
复制代码
packages/desktop-client/
├── src/
│   ├── components/      # React 组件
│   │   ├── accounts/    # 账户相关组件
│   │   ├── budget/      # 预算相关组件
│   │   ├── reports/     # 报表组件
│   │   ├── transactions/# 交易组件
│   │   └── ...
│   ├── hooks/           # 自定义 React Hooks
│   ├── util/            # 工具函数
│   ├── App.tsx          # 应用根组件
│   └── ...
├── e2e/                # 端到端测试
└── package.json
6.4.2 自定义 Hook 示例
typescript 复制代码
// packages/desktop-client/src/hooks/useSync.ts

import { useState, useEffect, useCallback } from 'react';
import * as lootCore from '@actual-app/loot-core';

export function useSync() {
  const [syncing, setSyncing] = useState(false);
  const [lastSyncTimestamp, setLastSyncTimestamp] = useState<number | null>(null);
  const [error, setError] = useState<Error | null>(null);
  
  // 执行同步
  const sync = useCallback(async () => {
    setSyncing(true);
    setError(null);
    
    try {
      // 1. 获取上次同步时间
      const lastSync = await lootCore.getLastSyncTimestamp();
      
      // 2. 上传本地新消息
      const localMessages = await lootCore.getLocalMessages(lastSync);
      if (localMessages.length > 0) {
        await lootCore.uploadMessages(localMessages);
      }
      
      // 3. 下载远程新消息
      const remoteMessages = await lootCore.downloadMessages(lastSync);
      if (remoteMessages.length > 0) {
        await lootCore.applyRemoteMessages(remoteMessages);
      }
      
      // 4. 更新同步时间
      const now = Date.now();
      await lootCore.setLastSyncTimestamp(now);
      setLastSyncTimestamp(now);
      
    } catch (err) {
      setError(err as Error);
    } finally {
      setSyncing(false);
    }
  }, []);
  
  // 自动同步(每7天)
  useEffect(() => {
    const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000;
    
    if (lastSyncTimestamp === null || (Date.now() - lastSyncTimestamp) > SEVEN_DAYS) {
      sync();
    }
    
    // 定期检查
    const interval = setInterval(() => {
      if ((Date.now() - lastSyncTimestamp!) > SEVEN_DAYS) {
        sync();
      }
    }, 60 * 60 * 1000);  // 每小时检查一次
    
    return () => clearInterval(interval);
  }, [lastSyncTimestamp, sync]);
  
  return {
    syncing,
    lastSyncTimestamp,
    error,
    sync
  };
}

源码位置packages/desktop-client/src/hooks/


七、效果对比

7.1 Actual Budget vs YNAB (You Need A Budget)

对比维度 Actual Budget YNAB
价格 🟢 完全免费开源 🔴 14.99/月 或 98.99/年
数据所有权 🟢 本地存储,用户完全掌控 🔴 云端存储,依赖服务商
离线使用 🟢 完全离线可用 🟡 有限离线功能
隐私 🟢 可选同步,隐私优先 🔴 所有数据上传云端
同步 🟢 自托管或 PikaPods 托管 🔴 强制云端同步
功能 🟢 信封预算法、报表、账户管理等 🟢 功能类似
开源 🟢 100% 开源 🔴 闭源
社区 🟢 活跃开源社区 🔴 商业支持
技术栈 🟢 现代技术栈 (TypeScript, React) ❓ 未知

结论 :如果你重视数据隐私零成本,Actual Budget 是 YNAB 的最佳替代品。

7.2 本地优先 vs 传统云同步

对比维度 本地优先 (Actual) 传统云同步 (YNAB)
响应速度 🟢 本地即时响应 🔴 依赖网络延迟
离线能力 🟢 完全离线可用 🔴 功能受限
可靠性 🟢 本地始终可用 🔴 云端故障影响使用
数据隐私 🟢 用户掌控数据 🔴 依赖服务商信誉
同步复杂度 🔴 需要 CRDT 等复杂技术 🟢 相对简单
冲突解决 🔴 需要自动冲突解决 🟢 中央服务器协调
存储成本 🟢 本地存储免费 🔴 服务端存储成本

结论 :本地优先架构在用户体验隐私方面有巨大优势,但技术实现更复杂。

7.3 CRDT vs 传统冲突解决

对比维度 CRDT (Actual) 中央协调 (传统) OT (Operational Transformation)
冲突解决 🟢 自动无冲突 🟢 服务器协调 🟡 需要复杂算法
去中心化 🟢 完全去中心化 🔴 依赖中央服务器 🟡 通常需要中央服务器
实现复杂度 🔴 较高 🟢 低 🔴 非常高
性能 🟢 本地优先,快速 🔴 依赖网络 🟡 中等
最终一致性 🟢 保证 🟢 保证 🟢 保证
适用场景 分布式协同编辑 客户端-服务器 实时协同编辑

结论 :CRDT 非常适合分布式离线优先的应用场景,但实现复杂度较高。


八、总结与展望

8.1 技术亮点

  1. 本地优先架构 - 用户体验极佳,离线完全可用
  2. CRDT 同步引擎 - 自动冲突解决,无需用户干预
  3. Monorepo 工程化 - 代码复用率高,维护性好
  4. 现代技术栈 - TypeScript + React + Vite,开发体验好
  5. 活跃社区 - 2026年仍在高频更新,AI 辅助开发

8.2 适用场景

适合

  • 重视数据隐私的用户
  • 需要离线使用的场景
  • 开源软件爱好者
  • 自托管解决方案

不适合

  • 需要实时多人协作(CRDT 是最终一致性)
  • 不需要本地存储的纯 Web 应用
  • 对 UI 定制要求极高的场景

8.3 学习价值

对于开发者,Actual Budget 是一个非常好的学习案例:

  1. 本地优先架构实践 - 如何在现实中实现本地优先
  2. CRDT 应用 - 如何在实际项目中使用 CRDT
  3. Monorepo 管理 - Yarn Workspaces 的最佳实践
  4. Electron 开发 - 如何构建跨平台桌面应用
  5. 开源项目管理 - 如何维护一个活跃的开源项目

8.4 参考文献


附录:快速部署指南

A.1 Docker 部署(推荐)

bash 复制代码
# 1. 拉取镜像
docker pull actualbudget/actual-server:latest

# 2. 运行容器
docker run -d \
  --name actual-server \
  -p 5006:5006 \
  -v /path/to/data:/data \
  -e TRUSTED_PROXIES=192.168.0.0/16 \
  actualbudget/actual-server:latest

# 3. 访问应用
# 打开浏览器访问 http://localhost:5006

A.2 本地开发环境搭建

bash 复制代码
# 1. 克隆仓库
git clone https://github.com/actualbudget/actual.git
cd actual

# 2. 安装依赖 (需要 Node.js >= 22.18.0)
corepack enable
yarn install

# 3. 启动开发服务器
yarn dev

# 4. 访问应用
# 桌面应用会自动打开,或访问 http://localhost:3000

A.3 PikaPods 一键部署

访问 PikaPods,点击 "Deploy" 按钮,无需任何配置即可部署 Actual Budget(约 $1.40/月)。