Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
8bce07c
feat(sync): introduce scoped per-note receipt primitives and regressi…
cropflre Oct 10, 2026
bba5536
feat(sync): track browser note revisions and server-confirmed versions
cropflre Oct 10, 2026
fc1bcc5
fix(sync): preserve in-flight queued edits and acknowledge exact vers…
cropflre Oct 10, 2026
41ce754
feat(sync): persist per-note ACK in Android SQLite transaction
cropflre Oct 10, 2026
4ff8809
feat(ui): show scoped note sync receipts without global false positives
cropflre Oct 10, 2026
7aca473
test(sync): gate per-note receipt semantics with DCC-011 and CI
cropflre Oct 10, 2026
d3ca0a1
test(sync): cover stale in-flight ACK against newer queued edit
cropflre Oct 10, 2026
f318f93
test(ui): require per-note evidence before showing cloud-confirmed st…
cropflre Oct 10, 2026
34d7186
fix(sync): never mistake Electron local REST writes for cloud ACK
cropflre Oct 10, 2026
d8bacc6
fix(sync): do not call metadata-only CRDT writes cloud confirmed
cropflre Oct 10, 2026
d858ff5
fix(android-sync): invalidate receipts after local commit and persist…
cropflre Oct 10, 2026
b7cf295
fix(android-sync): ignore proven own pull echo while newer note mutat…
cropflre Oct 10, 2026
8a29c0b
test(android-sync): applied/duplicate ACK versions and own-echo confl…
cropflre Oct 10, 2026
b273866
test(android-sync): verify committed note edits invalidate stale rece…
cropflre Oct 10, 2026
89e0698
test(ui): stale Android ACK switches to pending after committed write…
cropflre Oct 10, 2026
3ea71a8
test(sync): make Android receipt, ACK version and own-echo protection…
cropflre Oct 10, 2026
17827a1
test(sync): align Android regressions with executable DCC title verif…
cropflre Oct 10, 2026
fb886ea
test(android-sync): correct TypeScript mock signatures for receipt in…
cropflre Oct 10, 2026
e6d10fb
test(android-sync): narrow optional SQL parameter arrays in TS accept…
cropflre Oct 10, 2026
2e953f4
test(ui): verify actual SyncStatusBadge reacts to committed Android e…
cropflre Oct 10, 2026
6c438f8
ci: retrigger PR #821 checks against updated release/v1.5.2
cropflre Oct 10, 2026
63912d3
test(lint): use typed fixtures in Sync Receipt regression tests
cropflre Oct 10, 2026
9602729
test(lint): use typed fixtures in Sync Receipt regression tests
cropflre Oct 10, 2026
49e3cc9
test(lint): use typed fixtures in Sync Receipt regression tests
cropflre Oct 10, 2026
9b8e7e6
test(lint): use typed fixtures in Sync Receipt regression tests
cropflre Oct 10, 2026
b870759
test(lint): use typed fixtures in Sync Receipt regression tests
cropflre Oct 10, 2026
d6dcbd1
test(lint): use typed fixtures in Sync Receipt regression tests
cropflre Oct 10, 2026
006db1d
test(sync): preserve typed SQL mock call arguments without unused vars
cropflre Oct 10, 2026
d964fff
merge(release/v1.5.2): share audited lint baseline and i18n with Sync…
cropflre Oct 10, 2026
7e47480
fix(i18n): preserve base encrypted password policy labels during Sync…
cropflre Oct 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/data-consistency-contract-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ on:
- "backend/tests/backup-restore-*.test.ts"
- "backend/tests/offline-workspace-sync.test.ts"
- "frontend/src/lib/mobileSync*"
- "frontend/src/lib/mobileNoteSyncReceipt.ts"
- "frontend/src/lib/noteSyncReceipt.ts"
- "frontend/src/lib/useNoteSyncReceipt.ts"
- "frontend/src/components/SyncStatusBadge.tsx"
- "frontend/src/components/EditorPane.tsx"
- "frontend/src/i18n/coverageTranslations.ts"
- "frontend/src/lib/mobileLocalFirst*"
- "frontend/src/lib/nativeLocal*"
- "frontend/src/lib/offline*"
Expand All @@ -28,6 +34,12 @@ on:
- "frontend/src/lib/__tests__/syncEngine*.test.ts"
- "frontend/src/lib/__tests__/noteSyncSafety*.test.ts"
- "frontend/src/lib/__tests__/offlineQueue.test.ts"
- "frontend/src/lib/__tests__/noteSyncReceipt.test.ts"
- "frontend/src/lib/__tests__/mobileNoteSyncReceipt.test.ts"
- "frontend/src/lib/__tests__/noteSyncReceiptBadge.test.tsx"
- "frontend/src/lib/__tests__/mobileNativeReceiptIntegration.test.ts"
- "frontend/src/lib/__tests__/useNoteSyncReceipt.nativeWiring.test.tsx"
- "frontend/src/lib/__tests__/nativeLocalNoteList.test.ts"
pull_request:
paths:
- "docs/architecture/data-consistency-contract*"
Expand All @@ -45,6 +57,12 @@ on:
- "backend/tests/backup-restore-*.test.ts"
- "backend/tests/offline-workspace-sync.test.ts"
- "frontend/src/lib/mobileSync*"
- "frontend/src/lib/mobileNoteSyncReceipt.ts"
- "frontend/src/lib/noteSyncReceipt.ts"
- "frontend/src/lib/useNoteSyncReceipt.ts"
- "frontend/src/components/SyncStatusBadge.tsx"
- "frontend/src/components/EditorPane.tsx"
- "frontend/src/i18n/coverageTranslations.ts"
- "frontend/src/lib/mobileLocalFirst*"
- "frontend/src/lib/nativeLocal*"
- "frontend/src/lib/offline*"
Expand All @@ -54,6 +72,12 @@ on:
- "frontend/src/lib/__tests__/syncEngine*.test.ts"
- "frontend/src/lib/__tests__/noteSyncSafety*.test.ts"
- "frontend/src/lib/__tests__/offlineQueue.test.ts"
- "frontend/src/lib/__tests__/noteSyncReceipt.test.ts"
- "frontend/src/lib/__tests__/mobileNoteSyncReceipt.test.ts"
- "frontend/src/lib/__tests__/noteSyncReceiptBadge.test.tsx"
- "frontend/src/lib/__tests__/mobileNativeReceiptIntegration.test.ts"
- "frontend/src/lib/__tests__/useNoteSyncReceipt.nativeWiring.test.tsx"
- "frontend/src/lib/__tests__/nativeLocalNoteList.test.ts"
workflow_dispatch:

permissions:
Expand Down
71 changes: 71 additions & 0 deletions docs/architecture/data-consistency-contract.matrix.json
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,77 @@
"case": "preserves a stale offline edit as one pending conflict without issuing PUT"
}
]
},
{
"id": "DCC-011",
"title": "逐笔记同步收据以当前修改的版本/Mutation ACK 为依据",
"status": "covered",
"checks": [
{
"file": "frontend/src/lib/__tests__/noteSyncReceipt.test.ts",
"case": "requires exact local revision before acknowledging a note write"
},
{
"file": "frontend/src/lib/__tests__/noteSyncReceipt.test.ts",
"case": "preserves pending queue and conflict priority over an older ACK"
},
{
"file": "frontend/src/lib/__tests__/noteSyncReceipt.test.ts",
"case": "isolates receipt data across account and server scopes"
},
{
"file": "frontend/src/lib/__tests__/mobileNoteSyncReceipt.test.ts",
"case": "requires a matching native mutation receipt instead of global sync time"
},
{
"file": "frontend/src/lib/__tests__/mobileNoteSyncReceipt.test.ts",
"case": "keeps native pending and conflicts ahead of committed ACK"
},
{
"file": "frontend/src/lib/__tests__/offlineQueue.test.ts",
"case": "does not discard a newer queued edit after a stale in-flight HTTP 200"
},
{
"file": "frontend/src/lib/__tests__/noteSyncReceiptBadge.test.tsx",
"case": "never uses global synced state to claim a note has cloud confirmation"
},
{
"file": "frontend/src/lib/__tests__/noteSyncReceipt.test.ts",
"case": "does not treat an Electron local SQLite response as a cloud ACK"
},
{
"file": "frontend/src/lib/__tests__/noteSyncReceipt.test.ts",
"case": "does not confirm a metadata-only REST response while CRDT body is unresolved"
},
{
"file": "frontend/src/lib/__tests__/mobileNativeReceiptIntegration.test.ts",
"case": "stores the actual applied and duplicate versions in an atomic mutation ACK"
},
{
"file": "frontend/src/lib/__tests__/mobileNativeReceiptIntegration.test.ts",
"case": "keeps conflict.serverVersion separate from successful applied.version"
},
{
"file": "frontend/src/lib/__tests__/mobileNativeReceiptIntegration.test.ts",
"case": "ignores only the known own ACK echo with a newer queued edit, without overwriting local"
},
{
"file": "frontend/src/lib/__tests__/mobileNativeReceiptIntegration.test.ts",
"case": "keeps real competing remote modifications as conflicts (not own echoes)"
},
{
"file": "frontend/src/lib/__tests__/nativeLocalNoteList.test.ts",
"case": "refreshes both Markdown and rich-text notes only after new Outbox commits"
},
{
"file": "frontend/src/lib/__tests__/useNoteSyncReceipt.nativeWiring.test.tsx",
"case": "does not repaint an obsolete confirmed query after a newer pending refresh"
},
{
"file": "frontend/src/lib/__tests__/useNoteSyncReceipt.nativeWiring.test.tsx",
"case": "updates the real editor badge after a committed Android edit"
}
]
}
]
}
5 changes: 3 additions & 2 deletions docs/architecture/data-consistency-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
- **DCC-008 — 备份恢复必须先校验、失败要保护原数据。** 附件和数据库快照校验失败时不得覆盖正在使用的数据;发生中途失败,原数据库及受管文件应有回滚/保护路径。
- **DCC-009 — Native 离线与身份安全。** Native 仅此设备模式的编辑在重启/初始化后仍保留;重新连接服务器前不能偷发写入;同步成功后方可清除相应待发送 mutation。
- **DCC-010 — Web 离线并不是云端确认。** Web 端失败写入、冲突快照必须保留在已有浏览器队列/冲突存储中,不能把“排队中”渲染成“服务器最新”;浏览器离线存储的实际耐久性仍受浏览器环境限制。
- **DCC-011 — 逐笔记同步收据须有对应证据。** Web 按账号/服务器作用域保存本地逻辑修订,只有包含完整正文快照的本次版本化写入得到服务端版本推进且无更新的本地修订,才能确认;元数据 REST ACK 不能证明 CRDT 正文已经到达云端。离线队列与冲突优先于旧 ACK。Android 仅依据同一 Profile、同一笔记的 Outbox、冲突以及与 Outbox 出队同事务写入的 mutationId ACK;Native 本地笔记 CRUD 必须在事务提交后立即刷新收据(包括旧 ACK 后的新修改),`applied/duplicate` 结果用 `version` 而非冲突专用 `serverVersion`;回拉只允许通过同一 Profile/笔记的精确 ACK 版本跳过已确认的自身回声,真实远端并发修改仍保留冲突;全局 lastSyncAt 和全局队列空不能证明单篇笔记已同步。缺失证据只能显示“待核验”。

## 3. 关键时序约束

Expand Down Expand Up @@ -89,8 +90,8 @@ GitHub Actions:`.github/workflows/data-consistency-contract-ci.yml`,对同

**尚未统一实现,后续应另开 Issue/PR:**

- 为所有编辑器和轻量表格统一 `localRevision / acknowledgedRevision / conflict` 的**同步收据**(Sync Receipt)与原子状态更新;不要先造假的“已同步”标签。
- 基于该收据统一「正在保存 / 已保存到本机 / 等待云端 / 云端已确认 / 冲突 / 失败」文案,区分服务器持久化与其他设备应用完毕。
- 本阶段已为普通笔记编辑器实现逐笔记 Sync Receipt;轻量表格、任务、附件和全部其他保存入口尚未覆盖。需要继续统一原生端本地逻辑修订号、服务端 ACK 版本,以及多平台设备级验收。
- 本阶段显示「正在保存 / 等待云端 / 云端已确认 / 冲突 / 失败 / 待核验」;Electron 内嵌后端的 REST 成功仅代表本地 SQLite 写入,不可据此确认云端;“已保存到本机”尚未覆盖全部 Web 本地存储失败场景,未获持久化证明时不得显示。服务端确认不等于附件或第二设备已应用。
- Native Android/iOS、桌面与 Web 的真实设备断网、切账号、重启、强杀、磁盘不足、代理异常、附件一致性全链路验收。
- 备份/恢复在 SQLite 与 PostgreSQL、对象存储等部署组合下的生产级恢复演练和附件逐项校验报告。

Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/EditorPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2604,7 +2604,7 @@ const moveToTrash = useCallback(async () => {
<MobileEditorToolbarPortal location="trailing">
{compactMobileEditing ? (
<div className="flex items-center gap-1">
<SyncStatusBadge saving={syncStatus === "saving" || syncStatus === "error"} />
<SyncStatusBadge noteId={activeNote.id} saving={syncStatus === "saving"} />
<Button
data-mobile-note-menu-trigger
variant="ghost"
Expand Down Expand Up @@ -2714,7 +2714,7 @@ const moveToTrash = useCallback(async () => {
本地写入成功就是"已保存",同步失败只叫"等待同步",
绝不能让一次网络抖动显示成"保存失败"。
Android 保留轻量文字,未开启同步时不渲染。 */}
{!compactMobileEditing && <SyncStatusBadge saving={syncStatus === "saving" || syncStatus === "error"} />}
{!compactMobileEditing && <SyncStatusBadge noteId={activeNote.id} saving={syncStatus === "saving"} />}
<span className="flex shrink-0 items-center gap-0.5" aria-hidden="true">
{activeNote.isLocked || isViewLocked ? <Lock size={12} className={activeNote.isLocked ? "text-orange-500" : "text-tx-tertiary"} /> : null}
{activeNote.isPinned ? <Pin size={12} className="text-accent-primary fill-accent-primary" /> : null}
Expand Down
19 changes: 17 additions & 2 deletions frontend/src/components/SyncStatusBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,29 @@ import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { useSyncIndicator } from "@/lib/useSyncIndicator";
import { isAndroidNativeRuntime } from "@/lib/mobileLocalMode";
import { useNoteSyncReceipt } from "@/lib/useNoteSyncReceipt";

export default function SyncStatusBadge({ className, saving = false }: { className?: string; saving?: boolean }) {
export default function SyncStatusBadge({ className, saving = false, noteId }: { className?: string; saving?: boolean; noteId?: string }) {
const { t } = useTranslation();
const sync = useSyncIndicator(saving);
const android = isAndroidNativeRuntime();
const receipt = useNoteSyncReceipt(noteId, sync.syncEnabled);

// 未启用同步:不占用任何空间。
if (!sync.syncEnabled || saving) return null;
if (!sync.syncEnabled) return null;
if (noteId) {
const phase = saving ? "saving" : receipt;
const warning = phase === "conflict" || phase === "error";
return (
<span role="status" aria-live="polite" title={t("syncBadge.receiptHint")}
className={cn("flex shrink-0 items-center gap-1 whitespace-nowrap px-1.5 py-1 rounded-md text-[11px]",
warning ? "text-amber-600 dark:text-amber-400" : "text-tx-tertiary", className)}>
{warning ? <AlertTriangle size={12} /> : phase === "confirmed" ? <Check size={12} /> : <RefreshCw size={12} />}
<span>{t(`syncBadge.receipt.${phase}`)}</span>
</span>
);
}
if (saving) return null;

// 一切正常时保持安静。有冲突则必须提示,即使状态是 synced ——
// 冲突不会自动消失,用户需要去处理。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const mocks = vi.hoisted(() => ({ settings: vi.fn(), diagnostics: vi.fn() }));
vi.mock("@/lib/syncLocalApi", () => ({
fetchSyncSettings: mocks.settings, fetchSyncDiagnostics: mocks.diagnostics, SyncV2DisabledError: class extends Error {},
}));
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let root: Root, host: HTMLElement;
let pending = 0, attachments = 0;
const render = async (saving = false) => { await act(async () => root.render(<SyncStatusBadge saving={saving} />)); };
Expand Down
18 changes: 18 additions & 0 deletions frontend/src/i18n/coverageTranslations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,15 @@ export const zhCNCoverageTranslations = {
"verifyFirst": "请先通过已保存地址连接一次,确认服务器身份后即可自动切换。",
},
syncBadge: {
"receiptHint": "当前笔记的同步收据。确认仅代表服务器收到笔记修改,不代表附件和其他设备已更新。",
"receipt": {
"unverified": "云端状态待核验",
"saving": "正在保存…",
"pending": "等待云端确认",
"confirmed": "云端已确认",
"conflict": "版本冲突待处理",
"error": "同步遇到问题"
},
"conflicts": "{{count}} 个冲突待处理",
"pending": "待同步 {{count}}",
"syncing": "同步中… {{count}}",
Expand Down Expand Up @@ -764,6 +773,15 @@ export const enCoverageTranslations = {
"verifyFirst": "Connect through the saved address once to verify the server identity before automatic switching.",
},
syncBadge: {
"receiptHint": "Receipt for this note only. Confirmation does not guarantee attachment uploads or delivery to other devices.",
"receipt": {
"unverified": "Cloud status unverified",
"saving": "Saving…",
"pending": "Awaiting cloud confirmation",
"confirmed": "Cloud confirmed",
"conflict": "Version conflict",
"error": "Sync issue"
},
"conflicts": "{{count}} conflicts to resolve",
"pending": "{{count}} pending changes",
"syncing": "Syncing… {{count}}",
Expand Down
Loading
Loading