From f691574993421e601f5748f48b868845cf837103 Mon Sep 17 00:00:00 2001 From: cropflre <767308865@qq.com> Date: Sun, 11 Oct 2026 15:46:21 +0800 Subject: [PATCH 01/13] fix(sync): persist personal native tree creates and authorized missing bodies --- .github/workflows/android-local-first-ci.yml | 2 + .../tests/sync-v2-mobile-multidevice.test.ts | 48 ++++++++++ .../data-consistency-contract.matrix.json | 9 +- docs/sync-v2-native-tree-validation.md | 48 ++++++++++ .../mobileLocalFirstPasswordAccess.test.ts | 56 ++++++++++- .../mobileLocalKnowledgeTreeBridge.test.ts | 1 + .../mobileSignedInTreeCreate.test.ts | 84 +++++++++++++++++ frontend/src/lib/mobileLocalFirstBridge.ts | 30 ++++-- .../src/lib/mobileLocalKnowledgeTreeBridge.ts | 64 ++++++++++++- .../src/lib/mobileLocalNotePasswordGuard.ts | 10 +- frontend/src/lib/nativeLocalRepository.ts | 94 +++++++++++++++++-- 11 files changed, 421 insertions(+), 25 deletions(-) create mode 100644 docs/sync-v2-native-tree-validation.md create mode 100644 frontend/src/lib/__tests__/mobileSignedInTreeCreate.test.ts diff --git a/.github/workflows/android-local-first-ci.yml b/.github/workflows/android-local-first-ci.yml index 78dbde2b9..2cdd50cbf 100644 --- a/.github/workflows/android-local-first-ci.yml +++ b/.github/workflows/android-local-first-ci.yml @@ -68,6 +68,8 @@ jobs: npx vitest run src/lib/__tests__/mobileLocalFirstGuestRuntime.test.ts src/lib/__tests__/mobileLocalKnowledgeTreeBridge.test.ts + src/lib/__tests__/mobileSignedInTreeCreate.test.ts + src/lib/__tests__/mobileLocalFirstPasswordAccess.test.ts src/lib/__tests__/mobileLocalAdvancedTaskBridge.test.ts src/lib/__tests__/mobileLocalNoteRelationsBridge.test.ts src/lib/__tests__/mobileLocalAttachmentFolderBridge.test.ts diff --git a/backend/tests/sync-v2-mobile-multidevice.test.ts b/backend/tests/sync-v2-mobile-multidevice.test.ts index c57f94e77..79de48d8d 100644 --- a/backend/tests/sync-v2-mobile-multidevice.test.ts +++ b/backend/tests/sync-v2-mobile-multidevice.test.ts @@ -116,6 +116,54 @@ test.after(async () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); +test("personal tree creates are readable offline, survive reopen and converge with stable root identities", async () => { + const name = "tree-offline", a = await client(name); + const ids: string[] = []; + for (const contentFormat of ["markdown", "tiptap-json"] as const) { + const id = randomUUID(); ids.push(id); + await a.repository.createPersonalTreeNote({ id, title: "Offline root", contentFormat, content: "offline body" }, null); + assert.equal((await a.repository.notes.get(id))?.content, "offline body"); + } + assert.equal((await a.repository.listLocalTreeDrafts()).length, 2); + assert.equal((await a.repository.notebooks.list()).length, 0, "internal root container stays hidden"); + await a.db.close(); + const reopened = await client(name), b = await client("tree-reader"); + assert.equal((await reopened.repository.listLocalTreeDrafts()).length, 2); + for (const id of ids) assert.equal((await reopened.repository.notes.get(id))?.content, "offline body"); + await reopened.sync(); await b.sync(); + assert.equal((await pending(reopened)).length, 0); + for (const id of ids) { + assert.equal((await b.repository.notes.get(id))?.content, "offline body"); + assert.equal(serverDb.prepare("SELECT parentId FROM knowledge_tree_nodes WHERE id=?").get(`note:${id}`).parentId, null); + } +}); + +test("tree create rolls back body, root container and provenance when Outbox insertion fails", async () => { + const c = await client("tree-rollback"), id = randomUUID(); + await c.db.run("CREATE TRIGGER reject_tree_outbox BEFORE INSERT ON sync_outbox BEGIN SELECT RAISE(ABORT,'fixture outbox failure'); END"); + await assert.rejects(c.repository.createPersonalTreeNote({ id, contentFormat: "markdown" }, null), /outbox failure/); + assert.equal(await c.repository.notes.get(id), null); + assert.equal((await c.db.query("SELECT id FROM notebooks")).length, 0); + assert.equal((await c.repository.listLocalTreeDrafts()).length, 0); +}); + +test("authorized hydration persists a remote body without creation mutations and preserves newer local writes", async () => { + const c = await client("hydrate"), id = randomUUID(), book = randomUUID(); + const notebook = { id: book, userId: USER, workspaceId: null, name: "Remote folder", parentId: null } as never; + const remote = { id, userId: USER, workspaceId: null, notebookId: book, title: "Remote", content: "remote body", contentFormat: "markdown", version: 4 } as never; + await c.repository.hydrateAuthorizedNote(remote, notebook); + assert.equal((await c.repository.notes.get(id))?.content, "remote body"); + assert.equal((await pending(c)).length, 0); + await c.repository.notes.update(id, { content: "new local body" }); + await c.repository.hydrateAuthorizedNote(remote, notebook); + assert.equal((await c.repository.notes.get(id))?.content, "new local body"); + assert.equal((await pending(c))[0].baseVersion, 4); + await c.repository.notes.remove(id); + await assert.rejects(c.repository.hydrateAuthorizedNote(remote, notebook), /本机.*删除/); + assert.equal(await c.repository.notes.get(id), null); + await assert.rejects(c.repository.hydrateAuthorizedNote({ ...remote, id: randomUUID(), userId: "other-user" }, notebook), /身份|空间/); +}); + test("workspace Snapshot pruning preserves pending descendants, unresolved conflicts and local attachment bytes", async () => { const c = await client("prune-native"); const key = "workspace:prune", book = randomUUID(), pendingNote = randomUUID(), conflictedNote = randomUUID(), attachment = randomUUID(); diff --git a/docs/architecture/data-consistency-contract.matrix.json b/docs/architecture/data-consistency-contract.matrix.json index 11e347a7b..4ddf670e5 100644 --- a/docs/architecture/data-consistency-contract.matrix.json +++ b/docs/architecture/data-consistency-contract.matrix.json @@ -275,7 +275,14 @@ { "file": "frontend/src/lib/__tests__/mobileSyncEndpoint.test.ts", "case": "rejects a late push ACK when the account changes" }, { "file": "backend/tests/sync-v2-mobile-multidevice.test.ts", "case": "workspace Snapshot pruning preserves pending descendants" }, { "file": "backend/tests/sync-v2-mobile-multidevice.test.ts", "case": "create then permanent delete between Pulls" }, - { "file": "frontend/src/lib/encryptedNotes/__tests__/nativeDatabaseInitialization.test.ts", "case": "external UI reads wait for an in-progress Apply transaction" } + { "file": "frontend/src/lib/encryptedNotes/__tests__/nativeDatabaseInitialization.test.ts", "case": "external UI reads wait for an in-progress Apply transaction" }, + { "file": "backend/tests/sync-v2-mobile-multidevice.test.ts", "case": "personal tree creates are readable offline, survive reopen" }, + { "file": "backend/tests/sync-v2-mobile-multidevice.test.ts", "case": "tree create rolls back body, root container and provenance" }, + { "file": "backend/tests/sync-v2-mobile-multidevice.test.ts", "case": "authorized hydration persists a remote body without creation mutations" }, + { "file": "frontend/src/lib/__tests__/mobileSignedInTreeCreate.test.ts", "case": "retains server checks for protected folders and workspaces" }, + { "file": "frontend/src/lib/__tests__/mobileSignedInTreeCreate.test.ts", "case": "does not project an ACKed note omitted by the live authorization tree" }, + { "file": "frontend/src/lib/__tests__/mobileLocalFirstPasswordAccess.test.ts", "case": "reads a locally created root with live server proofs" }, + { "file": "frontend/src/lib/__tests__/mobileLocalFirstPasswordAccess.test.ts", "case": "does not persist or return a remote body after its native session is disposed" } ] } ] diff --git a/docs/sync-v2-native-tree-validation.md b/docs/sync-v2-native-tree-validation.md new file mode 100644 index 000000000..2f69aed37 --- /dev/null +++ b/docs/sync-v2-native-tree-validation.md @@ -0,0 +1,48 @@ +# Native personal tree creation / Native 个人知识树创建 + +2026-10-11,基准 `856d42bf`,分支 `codex/native-knowledge-tree-offline`,目标 `release/v1.5.2`。本轮是同步可靠性第二批,所有测试内容均为临时模拟数据。 + +## 问题、决策与执行链路 + +Issue #829 的 Native 调用链存在可验证缺口:登录态知识树 `POST /knowledge-tree/nodes` 成功后,编辑器改从 Native Repository 读取。原 fallback 只返回远端正文,随后保存仍可能走远端,离线重开未具备持久化正文。手机浏览器也在 Issue 的报告范围内,但本轮不宣称其 IndexedDB/REST 路径已修复。 + +最小方案复用既有 `notebook`/`note` Sync V2 与 `note:` 树投影,不增加协议、依赖或 schema。完整 `knowledge_tree_node` 结构版本、移动、跨文档父子关系及共享 ACL 仍未进入 Native V2。 + +| 入口 | 修改前 | 修改后 | +| --- | --- | --- | +| 个人根级 MD/富文本创建 | 远端树创建 → 等待 Native Pull;断网失败 | 客户端 UUID → 同事务创建隐藏根容器、正文、创建来源和 Outbox → 立即读取 → 既有 Push/Pull/ACK | +| 个人普通文件夹内 MD/富文本创建 | 始终调用远端树 API | 校验缓存的真实父链、无密码、canCreate 与本机拥有的父文件夹 → 本地事务;缓存缺失、受保护/共享/跨文档目录继续服务端检查 | +| 本机尚无的已授权个人正文 | 远端授权/读取 → 返回临时正文 | 远端授权/读取 → Native 父容器和正文事务落盘,不插入创建 Outbox → 重新校验目录密码/会话 → 本地保存进入既有版本队列 | +| 在线树尚无本机新文档 | 服务端列表无法显示它 | 只补本机创建事务记录的个人文档;真实服务端节点出现后接管结构与权限,不投影其他远端缓存 | + +本机创建来源放在已有 `native_runtime_meta`,不把一次发送当作数据来源改变。服务端确认后若在线授权树省略该文档,不允许用来源记录重新构造无保护节点。树列表只跨 Native bridge 返回元数据,不桥接正文或大预览。隐藏根容器复用 `__nowen_root_documents__:personal:`,不会显示成普通文件夹。 + +## 已执行回归 + +- 后端全部 `sync-v2-*.test.ts`、加密同步及 Native 加密存储:**267/267**,独立临时数据库、串行执行;多库套件 **17/17**。新增根级两格式创建/关闭重开/第二设备收敛/服务器树 ID、创建失败整体回滚、授权落盘/保留新编辑/删除不复活/他人身份拒绝。 +- 契约映射的前端 17 文件:**141/141**。另 4 文件的真实 SQLite 账号迁移、Guest Runtime、知识树 Bridge、根文档策略:**27/27**,合计 21 个不同文件 **168/168**,无 skip。 +- 前端正常 `npm run build`、TypeScript:通过;历史大 chunk 警告保留。lint 基线 **1447 historical / 0 new errors**。 +- `node scripts/data-consistency-contract.mjs --verify`:**12 invariants / 66 references / 7 backend files / 17 frontend files**。 + +Windows 后端依赖是 Electron ABI,使用 `ELECTRON_RUN_AS_NODE=1` 的项目 Electron + tsx + `setup-db-isolation.ts`。初始 HTTP 用例在网络沙箱内失败,隔离回环测试经授权运行后通过;不是源码失败。Windows 契约 `--frontend` 入口存在既有 `spawnSync npx.cmd EINVAL`,已直接用 Node 24 执行其完整映射文件,未修改无关启动脚本。Vitest 的沙箱 realpath 限制同样通过批准的测试执行解决。 + +## 数据安全与兼容性 + +- 业务、创建来源、Outbox 同事务成功才返回;任一插入失败全部回滚。既有保存检查保持开启。 +- 已存在的本地正文优先,不覆盖本地后续编辑;存在本机删除/冲突/待处理意图时不从远端重新创建。只持久化当前账号拥有的个人正文;共享与工作区沿用原授权远端读取。 +- 目录密码不降级为无保护投影;完整真实父链缺失/循环/权限不足拒绝本地创建,保护目录继续服务端检查。落盘后再次检查密码与会话,旧账号回包不能返回新会话。 +- 读取落盘不产生第二个 creation mutation,不修改同步 Cursor,不伪造 mutation ACK 或其他设备收据。标签与附件仍由既有 Pull/transfer 获取,不宣称仅正文落盘代表完整附件同步。 +- 无迁移、无清库、无删除真实数据、无 main 修改、无强推或发布。原有 `backend/package-lock.json` 修改不纳入本轮提交。 + +## 完成度与下一步 + +| 能力 | 本轮范围 | 剩余限制 | +| --- | --- | --- | +| Native 个人根级 Markdown/富文本离线创建 | 已实现、独立 SQLite 回归通过 | Android 新包证据见后续记录;完整 EditorPane 视觉/编辑生命周期单独验收 | +| Native 普通个人文件夹内离线创建 | 已实现、Bridge 回归通过 | 父文件夹须已同步,真实服务端树/密码元数据已缓存 | +| #829 个人已授权正文持久化 | 已实现、回归通过 | 手机浏览器、共享/工作区正文与完整结构同步未完成;不关闭 Issue | +| Native 文件夹创建、任意节点移动/排序/删除冲突 | 既有远端路径 | 完整 Native `knowledge_tree_node` 能力继续下一批 | +| 密码目录、共享 ACL、附件 bytes | 保持原有检查 | 完整新包场景尚需专项验收 | +| Web、iOS、HarmonyOS、Docker/NAS、公网延迟 | 本轮无新增验收 | 沿用上一轮未完成清单,不能套用 Native/回环结论 | + +English: Personal native Markdown/rich-text root and ordinary-folder creation now commits content, provenance and the existing Sync V2 Outbox atomically. A live-authorized personal body missing locally is hydrated without a duplicate creation, with local edits/deletes and session/password guards preserved. This is a bounded legacy-entity bridge, not complete native tree-structure synchronization or mobile-browser acceptance. diff --git a/frontend/src/lib/__tests__/mobileLocalFirstPasswordAccess.test.ts b/frontend/src/lib/__tests__/mobileLocalFirstPasswordAccess.test.ts index a3b6f7699..7f7fad1f5 100644 --- a/frontend/src/lib/__tests__/mobileLocalFirstPasswordAccess.test.ts +++ b/frontend/src/lib/__tests__/mobileLocalFirstPasswordAccess.test.ts @@ -37,6 +37,9 @@ function setup(deviceOnly = false) { const remoteTree = vi.spyOn(knowledgeTreeApi, "listForWorkspace").mockResolvedValue({ nodes }); const authorize = vi.spyOn(api, "getNoteSlim").mockResolvedValue(notes[0]); const repository = { + listLocalTreeDrafts: vi.fn(async () => []), + listNotebooksForWorkspace: vi.fn(async () => []), + hydrateAuthorizedNote: vi.fn(async (incoming: Note) => incoming), notes: { get: vi.fn(async (id: string) => notes.find((item) => item.id === id)) }, listNotesForWorkspace: vi.fn(async () => notes), listNotesWithTags: vi.fn(async () => notes), @@ -53,7 +56,7 @@ function setup(deviceOnly = false) { } as unknown as NativeDatabase; if (deviceOnly) localStorage.setItem("nowen-mobile-force-local-mode", "1"); restore = installMobileLocalFirstBridge(repository, db, "user"); - return { remoteTree, authorize, repository, nodes, snapshots }; + return { remoteTree, authorize, repository, nodes, snapshots, db }; } beforeEach(() => { @@ -64,6 +67,57 @@ beforeEach(() => { afterEach(() => { restore?.(); restore = undefined; vi.restoreAllMocks(); Reflect.deleteProperty(window, "Capacitor"); }); describe("signed-in Android folder password access", () => { + it("reads a locally created root with live server proofs and after losing the first Push response", async () => { + const { repository, remoteTree, snapshots, db, authorize } = setup(); + const root = { ...note("local-root", "hidden-root"), userId: "user", isTrashed: 0, treeParentId: null }; + vi.mocked(repository.notes.get).mockResolvedValue(root); + vi.mocked(repository.listLocalTreeDrafts).mockResolvedValue([root]); + snapshots.set("localTreeDraft:personal:local-root", "null"); + vi.mocked(db.query).mockImplementation(async (sql, values = []) => sql.includes("sync_outbox") ? [{ id: "queued" }] as never + : snapshots.has(String(values[0])) ? [{ value: snapshots.get(String(values[0])) }] as never : []); + remoteTree.mockResolvedValue({ nodes: [], passwordAuthorizedNotes: [] }); + await expect(api.getNote(root.id)).resolves.toMatchObject({ id: root.id }); + remoteTree.mockRejectedValue(new TypeError("Failed to fetch")); + await expect(api.getNote(root.id)).resolves.toMatchObject({ id: root.id }); + expect(authorize).not.toHaveBeenCalled(); + }); + + it("persists an authorized personal tree body before returning and then edits locally", async () => { + const incoming = { ...note("hydrate-body", "public"), userId: "user" }; + vi.spyOn(api, "getNote").mockResolvedValue(incoming); + vi.spyOn(api, "getNotebooks").mockResolvedValue([{ id: "public", userId: "user", workspaceId: null }] as never); + const remoteUpdate = vi.spyOn(api, "updateNote"); + const { repository, authorize, remoteTree, nodes } = setup(); + authorize.mockResolvedValue(incoming); + remoteTree.mockResolvedValue({ nodes: [...nodes, { id: `note:${incoming.id}`, resourceType: "note", resourceId: incoming.id, + parentId: "public", workspaceId: null, isDeleted: 0, access: { capabilities: { canView: true } } } as KnowledgeTreeNode] }); + vi.mocked(repository.hydrateAuthorizedNote).mockImplementation(async (value) => { + vi.mocked(repository.notes.get).mockResolvedValue(value); + return value; + }); + repository.notes.update = vi.fn(async () => ({ id: incoming.id, savedAt: "now" })); + await expect(api.getNote(incoming.id)).resolves.toEqual(incoming); + expect(repository.hydrateAuthorizedNote).toHaveBeenCalledWith(incoming, expect.objectContaining({ id: "public" })); + await api.updateNote(incoming.id, { content: "offline edit" }); + expect(repository.notes.update).toHaveBeenCalledWith(incoming.id, { content: "offline edit" }); + expect(remoteUpdate).not.toHaveBeenCalled(); + }); + + it("does not persist or return a remote body after its native session is disposed", async () => { + const incoming = { ...note("late-body", "public"), userId: "user" }; + let finish!: (value: Note) => void; + const remoteGet = vi.spyOn(api, "getNote").mockImplementation(() => new Promise((resolve) => { finish = resolve; })); + const { repository, authorize } = setup(); + authorize.mockResolvedValue(incoming); + const read = api.getNote(incoming.id); + const rejected = expect(read).rejects.toThrow("会话已改变"); + await vi.waitFor(() => expect(remoteGet).toHaveBeenCalled()); + restore?.(); restore = undefined; + finish(incoming); + await rejected; + expect(repository.hydrateAuthorizedNote).not.toHaveBeenCalled(); + }); + it("opens and saves a server-tree note missing from Native SQLite without creating a duplicate", async () => { const incoming = note("just-created-on-server", "public"); const remoteGet = vi.spyOn(api, "getNote").mockResolvedValue(incoming); diff --git a/frontend/src/lib/__tests__/mobileLocalKnowledgeTreeBridge.test.ts b/frontend/src/lib/__tests__/mobileLocalKnowledgeTreeBridge.test.ts index 659750796..a93ccd152 100644 --- a/frontend/src/lib/__tests__/mobileLocalKnowledgeTreeBridge.test.ts +++ b/frontend/src/lib/__tests__/mobileLocalKnowledgeTreeBridge.test.ts @@ -69,6 +69,7 @@ function createRepository() { ]; const repository = { + listLocalTreeDrafts: vi.fn(async () => []), listNotebooksForWorkspace: vi.fn(async () => notebooks), listNotesForWorkspace: vi.fn(async (_workspaceId?: string, query?: { includeTrashed?: boolean }) => ( query?.includeTrashed ? notes : notes.filter((item) => item.isTrashed !== 1) diff --git a/frontend/src/lib/__tests__/mobileSignedInTreeCreate.test.ts b/frontend/src/lib/__tests__/mobileSignedInTreeCreate.test.ts new file mode 100644 index 000000000..6da90f68f --- /dev/null +++ b/frontend/src/lib/__tests__/mobileSignedInTreeCreate.test.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { knowledgeTreeApi, type KnowledgeTreeNode } from "../knowledgeTreeApi"; +import { installMobileLocalKnowledgeTreeBridge } from "../mobileLocalKnowledgeTreeBridge"; +import type { NativeLocalRepository } from "../nativeLocalRepository"; +import type { NativeDatabase } from "../nativeDatabase"; +import type { Note } from "@/types"; + +let restore: (() => void) | undefined; +afterEach(() => { restore?.(); restore = undefined; vi.restoreAllMocks(); }); + +function setup(parent?: KnowledgeTreeNode) { + const drafts: Array = []; + const metadata = new Map(); + let pending = true; + const db = { + run: vi.fn(async (sql: string, values: unknown[]) => { + if (sql.startsWith("DELETE")) metadata.delete(String(values[0])); + else metadata.set(String(values[0]), String(values[1])); + return { changes: 1 }; + }), + query: vi.fn(async (sql: string, values: unknown[]) => sql.includes("sync_outbox") ? (pending ? [{ id: "queued" }] : []) + : metadata.has(String(values[0])) ? [{ value: metadata.get(String(values[0])) }] : []), + } as unknown as NativeDatabase; + const repository = { + listLocalTreeDrafts: vi.fn(async () => drafts), + createPersonalTreeNote: vi.fn(async (input: Note, notebookId: string | null) => { + drafts.push({ ...input, userId: "user", workspaceId: null, notebookId: notebookId || "hidden-root", + isTrashed: 0, version: 1, treeParentId: notebookId ? `notebook:${notebookId}` : null }); + return { id: input.id, savedAt: "now" }; + }), + notes: { get: vi.fn(async (id: string) => drafts.find((note) => note.id === id)) }, + } as unknown as NativeLocalRepository; + const remoteCreate = vi.spyOn(knowledgeTreeApi, "createForWorkspace").mockRejectedValue(new TypeError("Failed to fetch")); + const remoteList = vi.spyOn(knowledgeTreeApi, "listForWorkspace").mockResolvedValue({ nodes: parent ? [parent] : [], passwordAuthorizedNotes: [] }); + restore = installMobileLocalKnowledgeTreeBridge(repository, { deviceOnly: false }, db); + return { repository, db, drafts, metadata, remoteCreate, remoteList, ack: () => { pending = false; } }; +} + +const folder = (protectedFolder: boolean): KnowledgeTreeNode => ({ + id: "notebook:folder", resourceId: "folder", parentId: null, resourceType: "notebook", nodeType: "folder", + workspaceId: null, isDeleted: 0, isPasswordProtected: protectedFolder ? 1 : 0, + access: { capabilities: { canView: true, canCreate: true } }, +}) as KnowledgeTreeNode; + +describe("signed-in native personal tree creation", () => { + it.each(["markdown", "note"] as const)("creates a %s root without network and keeps it visible after bridge restart", async (nodeType) => { + const state = setup(); + state.remoteList.mockRejectedValue(new TypeError("Failed to fetch")); + const node = await knowledgeTreeApi.createForWorkspace("personal", { parentId: null, nodeType, title: "Offline" }); + expect(state.remoteCreate).not.toHaveBeenCalled(); + expect(await state.repository.notes.get(node.resourceId)).toMatchObject({ title: "Offline" }); + expect(node.id).toBe(`note:${node.resourceId}`); + restore?.(); + restore = installMobileLocalKnowledgeTreeBridge(state.repository, { deviceOnly: false }, state.db); + expect((await knowledgeTreeApi.listForWorkspace("personal")).nodes).toMatchObject([{ id: node.id, parentId: null }]); + }); + + it("creates in a cached ordinary folder and hands structure over to the live server node", async () => { + const state = setup(folder(false)); + await knowledgeTreeApi.listForWorkspace("personal"); + state.remoteList.mockRejectedValueOnce(new TypeError("Failed to fetch")); + const node = await knowledgeTreeApi.createForWorkspace("personal", { parentId: "notebook:folder", nodeType: "markdown", title: "Child" }); + expect(state.repository.createPersonalTreeNote).toHaveBeenCalledWith(expect.anything(), "folder"); + state.remoteList.mockResolvedValue({ nodes: [folder(false), { ...node, parentId: "new-parent" }] }); + const tree = await knowledgeTreeApi.listForWorkspace("personal"); + expect(tree.nodes.filter((item) => item.resourceId === node.resourceId)).toHaveLength(1); + expect(tree.nodes.find((item) => item.id === node.id)?.parentId).toBe("new-parent"); + expect(state.db.run).toHaveBeenCalledWith("DELETE FROM native_runtime_meta WHERE key=?", [`localTreeDraft:personal:${node.resourceId}`]); + }); + + it("retains server checks for protected folders and workspaces", async () => { + const state = setup(folder(true)); + await expect(knowledgeTreeApi.createForWorkspace("personal", { parentId: "notebook:folder", nodeType: "note", title: "Protected" })).rejects.toThrow("Failed to fetch"); + await expect(knowledgeTreeApi.createForWorkspace("workspace", { parentId: null, nodeType: "note", title: "Shared" })).rejects.toThrow("Failed to fetch"); + expect(state.repository.createPersonalTreeNote).not.toHaveBeenCalled(); + }); + + it("does not project an ACKed note omitted by the live authorization tree", async () => { + const state = setup(); + await knowledgeTreeApi.createForWorkspace("personal", { parentId: null, nodeType: "note", title: "Root" }); + state.ack(); + expect((await knowledgeTreeApi.listForWorkspace("personal")).nodes).toEqual([]); + }); +}); diff --git a/frontend/src/lib/mobileLocalFirstBridge.ts b/frontend/src/lib/mobileLocalFirstBridge.ts index 16628deb4..29b0b492b 100644 --- a/frontend/src/lib/mobileLocalFirstBridge.ts +++ b/frontend/src/lib/mobileLocalFirstBridge.ts @@ -1,4 +1,4 @@ -import { api } from "./api"; +import { api, getBaseUrl } from "./api"; import { newLocalId } from "./localRepository"; import type { NativeLocalRepository } from "./nativeLocalRepository"; import type { Note, Notebook, Tag, Workspace } from "@/types"; @@ -29,6 +29,11 @@ export function installMobileLocalFirstBridge( ): () => void { if (installed) return () => undefined; installed = true; + let active = true; + const serverBase = getBaseUrl(); + const assertActive = () => { + if (!active || getBaseUrl() !== serverBase) throw new Error("笔记读取会话已改变,请重新打开"); + }; const target = api as Omit & { getVersion: () => Promise>>>; @@ -171,12 +176,9 @@ export function installMobileLocalFirstBridge( }); return deviceOnlyMode ? notes : passwordGuard.filterReadable(notes, params.workspaceId); }; - // In signed-in Android mode, the knowledge tree is intentionally server-first - // (folder ACL/passwords), while note content is local-first. A newly created - // server-side tree note can therefore be visible before Sync V2 has pulled it - // into Native SQLite. Do not claim that such a note is missing or enqueue a - // duplicate local creation. Read it through the original authenticated server - // API until the native synchronization catches up. + // Advanced tree operations still create on the server before native Pull. + // Read missing content through live authorization, then persist personal + // owned bodies so subsequent saves use the transactional Native Outbox. const readRemoteMissingNote = async (id: string): Promise => { if (typeof navigator !== "undefined" && navigator.onLine === false) { throw new Error("这篇笔记尚未同步到本机,请联网后重试"); @@ -187,7 +189,18 @@ export function installMobileLocalFirstBridge( if (slim.id !== id) throw new Error("笔记身份校验失败"); const remote = await originals.getNote(id); if (remote.id !== id) throw new Error("笔记身份校验失败"); - return remote; + assertActive(); + // Shared/workspace documents still need the complete native ACL/structure + // capability. Personal owned notes can be persisted without another create. + if (remote.workspaceId || remote.userId !== userId) return remote; + const notebook = (await repository.listNotebooksForWorkspace("personal")) + .find((item) => item.id === remote.notebookId) + || (await originals.getNotebooks("personal")).find((item) => item.id === remote.notebookId); + assertActive(); + const hydrated = await repository.hydrateAuthorizedNote(remote, notebook); + await passwordGuard.assertReadable(hydrated); + assertActive(); + return hydrated; }; target.getNote = async (id: string): Promise => { @@ -308,6 +321,7 @@ export function installMobileLocalFirstBridge( }; return () => { + active = false; passwordGuard.dispose(); // 子 Bridge 按安装的逆序恢复,避免 wrapper 恢复到另一个 wrapper 上。 restoreNoteRelationsBridge(); diff --git a/frontend/src/lib/mobileLocalKnowledgeTreeBridge.ts b/frontend/src/lib/mobileLocalKnowledgeTreeBridge.ts index a5cf0c1fa..5f4e7fb20 100644 --- a/frontend/src/lib/mobileLocalKnowledgeTreeBridge.ts +++ b/frontend/src/lib/mobileLocalKnowledgeTreeBridge.ts @@ -12,6 +12,7 @@ import { isMobileLocalMode } from "./mobileLocalMode"; import { ensureMobileLocalMindMapTree } from "./mobileLocalMindMapTree"; import type { NativeDatabase } from "./nativeDatabase"; import type { NativeLocalRepository } from "./nativeLocalRepository"; +import { createNoteFolderPasswordResolver } from "./knowledgeTreePassword"; function ownerAccess( nodeId: string, @@ -162,8 +163,8 @@ function localOnlyUnsupported(message: string): Error & { code?: string } { * Android Native 知识树 Bridge。 * * - 纯设备本地模式:列表从 Native Repository 投影,并接管 CRUD / batch / ACL。 - * - 已登录 Local-first:在线读取服务端统一树,断网回退服务端快照;mutation / 权限 / - * 密码等高级能力仍由服务端处理,直到 Native DB 支持完整结构同步。 + * - 已登录 Local-first:个人根级/普通文件夹的 MD/富文本可本地创建;本机创建记录 + * 补充服务端树或离线快照。结构移动、共享、密码等高级能力仍由服务端处理。 */ export function installMobileLocalKnowledgeTreeBridge( repository: NativeLocalRepository, @@ -200,6 +201,31 @@ export function installMobileLocalKnowledgeTreeBridge( // 登录态以服务端统一树为准;断网时只使用保留密码信息的服务端快照。 // 权限或服务端错误不能当作断网,否则可能显示已撤权的工作区内容。 if (!deviceOnly) { + const withLocalDrafts = async (result: KnowledgeTreeResponse, scope: string, online: boolean): Promise => { + if (!db || scope !== "personal") return result; + const drafts = await repository.listLocalTreeDrafts(); + if (!drafts.length) return result; + const nodes = [...result.nodes]; + for (const draft of drafts) { + const key = `localTreeDraft:personal:${draft.id}`; + const present = nodes.some((node) => node.resourceType === "note" && node.resourceId === draft.id); + // A live tree takes over ACL/structure. An ACKed note omitted by it must + // not be reconstructed as an unprotected local navigation node. + const pending = online && !present ? (await db.query("SELECT id FROM sync_outbox WHERE scopeKey='personal' AND entityType='note' AND entityId=? LIMIT 1", [draft.id])).length : true; + if (present || !pending) { await db.run("DELETE FROM native_runtime_meta WHERE key=?", [key]); continue; } + if (draft.treeParentId) { + const parent = nodes.find((node) => node.id === draft.treeParentId); + if (!parent || parent.isDeleted || !parent.access?.capabilities.canView + || createNoteFolderPasswordResolver(nodes)(draft, true)?.length !== 0) continue; + } + nodes.push({ ...noteNode(draft, false), parentId: draft.treeParentId, + access: ownerAccess(`note:${draft.id}`, { canCreate: false }) }); + } + const counts = new Map(); + if (nodes.length === result.nodes.length) return result; + for (const node of nodes) if (node.parentId) counts.set(node.parentId, (counts.get(node.parentId) || 0) + 1); + return { ...result, nodes: applyKnowledgeTreeSort(nodes.map((node) => ({ ...node, childCount: counts.get(node.id) || 0 }))) }; + }; const serverFirstList = async ( workspaceId: string | undefined, includeDeleted: boolean, @@ -223,7 +249,7 @@ export function installMobileLocalKnowledgeTreeBridge( console.warn("[mobile knowledge tree] snapshot cache write failed", error); } } - return result; + return withLocalDrafts(result, scope, true); } catch (error) { if (db && error && typeof error === "object" && "status" in error && error.status === 403) { if (scope !== "personal") { @@ -244,14 +270,20 @@ export function installMobileLocalKnowledgeTreeBridge( } const cached = (await db.query<{ value: string }>( "SELECT value FROM native_runtime_meta WHERE key=?", [key], - ))[0]; + ))[0] || (includeDeleted ? (await db.query<{ value: string }>( + "SELECT value FROM native_runtime_meta WHERE key=?", [snapshotKey(workspaceId, false)], + ))[0] : undefined); if (cached) { try { const nodes = JSON.parse(cached.value) as unknown; - if (validNodes(nodes)) return { nodes }; + if (validNodes(nodes)) return withLocalDrafts({ nodes }, scope, false); } catch { /* 损坏的快照不能证明目录没有密码。 */ } } } + // A locally created root has no inherited folder protection. This does + // not authorize any cached remote body or project other Native rows. + const drafts = await withLocalDrafts({ nodes: [] }, scope, false); + if (drafts.nodes.length) return drafts; // Native 投影不含文件夹密码,缺失/损坏的服务端快照不能降级成无保护目录。 throw new Error("目录权限和密码信息尚未缓存,请联网后重试"); } @@ -264,6 +296,28 @@ export function installMobileLocalKnowledgeTreeBridge( workspaceId, includeDeleted, () => originals.listForWorkspace(workspaceId, includeDeleted), ); + const createPersonal = async (workspaceId: string, input: Parameters[0]) => { + if (workspaceId !== "personal" || !db || (input.nodeType !== "note" && input.nodeType !== "markdown")) { + return originals.createForWorkspace(workspaceId, input); + } + let notebookId: string | null = null; + if (input.parentId) { + const { nodes } = await target.listForWorkspace("personal"); + const parent = nodes.find((node) => node.id === input.parentId); + if (!parent || parent.resourceType !== "notebook" || parent.isDeleted || !parent.access?.capabilities.canCreate + || createNoteFolderPasswordResolver(nodes)({ id: "", notebookId: parent.resourceId }, true)?.length !== 0) { + return originals.createForWorkspace(workspaceId, input); + } + notebookId = parent.resourceId; + } + const id = newLocalId(), title = input.title.trim() || "无标题笔记"; + await repository.createPersonalTreeNote({ id, title, contentFormat: input.nodeType === "markdown" ? "markdown" : "tiptap-json", + content: input.nodeType === "markdown" ? `# ${title}\n\n` : JSON.stringify({ type: "doc", content: [{ type: "paragraph" }] }) }, notebookId); + const note = (await repository.notes.get(id))!; + return { ...noteNode(note, false), parentId: input.parentId, access: ownerAccess(`note:${id}`, { canCreate: false }) }; + }; + target.create = (input) => createPersonal(getCurrentWorkspace(), input); + target.createForWorkspace = createPersonal; return () => { Object.assign(target, originals); }; } diff --git a/frontend/src/lib/mobileLocalNotePasswordGuard.ts b/frontend/src/lib/mobileLocalNotePasswordGuard.ts index f4959a232..3336b325a 100644 --- a/frontend/src/lib/mobileLocalNotePasswordGuard.ts +++ b/frontend/src/lib/mobileLocalNotePasswordGuard.ts @@ -50,8 +50,16 @@ export function createMobileLocalNotePasswordGuard(authorizeRemote: (id: string) : null; return { verified, - folders: (note: LocalNote) => { + folders: async (note: LocalNote) => { const node = liveNotes.get(note.id); + // A locally created personal draft is our own body, not a cached cloud + // authorization. Never apply this exception to inherited passwords. + if (!note.workspaceId && node && node.isDeleted === 0 && node.access?.capabilities.canView + && resolveFolders(note)?.length === 0) { + const draft = (await db.query<{ value: string }>("SELECT value FROM native_runtime_meta WHERE key=?", + [`localTreeDraft:personal:${note.id}`]))[0]; + if (draft && draft.value === JSON.stringify(node.parentId)) return []; + } // 在线授权携带真实保护链,不能依赖导航树提升共享节点后的父链。 if (verified && node) return Promise.resolve(resolveFolders(note) === null ? null : verified.get(note.id) ?? null); return resolveNoteFolders(note, resolveFolders); diff --git a/frontend/src/lib/nativeLocalRepository.ts b/frontend/src/lib/nativeLocalRepository.ts index 0431f3f8c..8201dd93c 100644 --- a/frontend/src/lib/nativeLocalRepository.ts +++ b/frontend/src/lib/nativeLocalRepository.ts @@ -12,6 +12,7 @@ import { newLocalId } from "./localRepository"; import { unsentLocalNoteKey } from "./nativeLocalNoteOrigin"; import { notifyMobileSyncStatusChanged } from "./mobileSyncStatus"; import { isEncryptedNoteFormat, validateEncryptedNoteWrite } from "./encryptedNotes/noteDocument"; +import { ROOT_DOCUMENT_NOTEBOOK_PREFIX } from "./rootDocumentCreatePolicy"; type EntityType = "notebook" | "note" | "tag" | "note_tag" | "favorite" | "attachment"; @@ -378,10 +379,10 @@ export class NativeLocalRepository implements LocalRepository { `, values); } - private async getNote(id: string, db: NativeDatabase = this.db): Promise { + private async getNote(id: string, db: NativeDatabase = this.db, requiredScopeKey?: string): Promise { const currentScopeKey = this.scope().scopeKey; - const rows = await db.query(`SELECT * FROM notes WHERE id=? - ORDER BY CASE WHEN scopeKey=? THEN 0 ELSE 1 END LIMIT 1`, [id,currentScopeKey]); + const rows = await db.query(`SELECT * FROM notes WHERE id=? ${requiredScopeKey ? "AND scopeKey=?" : ""} + ORDER BY CASE WHEN scopeKey=? THEN 0 ELSE 1 END LIMIT 1`, [id,...(requiredScopeKey ? [requiredScopeKey] : []),currentScopeKey]); if (!rows[0]) return null; const scopeKey = this.scopeFromWorkspace(rows[0].workspaceId).scopeKey; const tags = await db.query(` @@ -392,12 +393,12 @@ export class NativeLocalRepository implements LocalRepository { return { ...rows[0], tags }; } - private async createNote(input: Partial & { id: string }): Promise { + private async insertNote(tx: NativeDatabase, input: Partial & { id: string }, localCreation: boolean): Promise { validateEncryptedNoteWrite(input as Record); const scope = input.workspaceId !== undefined ? this.scopeFromWorkspace(input.workspaceId) : this.scope(); - await this.assertWritable(scope.scopeKey); + if (localCreation) await this.assertWritable(scope.scopeKey, tx); const savedAt = now(); const row = { id: input.id, scopeKey: scope.scopeKey, workspaceId: scope.workspaceId, @@ -409,18 +410,93 @@ export class NativeLocalRepository implements LocalRepository { trashedAt: input.trashedAt ?? null, version: Math.max(1, input.version || 1), sortOrder: input.sortOrder || 0, createdAt: input.createdAt || savedAt, updatedAt: savedAt, }; - await this.db.transaction(async (tx) => { - await tx.run(`INSERT INTO notes ( + await tx.run(`INSERT INTO notes ( id,scopeKey,workspaceId,userId,notebookId,title,content,contentText,contentFormat,colorMark, isPinned,isFavorite,isLocked,isArchived,isTrashed,trashedAt,version,sortOrder,createdAt,updatedAt ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, Object.values(row)); + if (localCreation) { await tx.run(`INSERT INTO native_runtime_meta (key,value,updatedAt) VALUES (?,'1',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updatedAt=excluded.updatedAt`, [unsentLocalNoteKey(scope.scopeKey, input.id), savedAt]); await this.enqueue(tx, "note", input.id, "upsert", row, undefined, scope.scopeKey); - }); + } + return { id: input.id, savedAt }; + } + + private async createNote(input: Partial & { id: string }): Promise { + const result = await this.db.transaction((tx) => this.insertNote(tx, input, true)); // The receipt is invalidated only after the SQLite Outbox transaction commits. notifyMobileSyncStatusChanged(); - return { id: input.id, savedAt }; + return result; + } + + private async ensurePersonalRootNotebook(tx: NativeDatabase, enqueue: boolean): Promise { + const id = `${ROOT_DOCUMENT_NOTEBOOK_PREFIX}personal:${this.userId}`; + const timestamp = now(); + const row = { id, scopeKey: "personal", workspaceId: null, userId: this.userId, parentId: null, + name: "__NOWEN_ROOT_DOCUMENTS__", icon: "📄", sortOrder: -2147483648, isExpanded: 0, + isDeleted: 1, deletedAt: timestamp, createdAt: timestamp, updatedAt: timestamp }; + const inserted = await tx.run(`INSERT OR IGNORE INTO notebooks + (id,scopeKey,workspaceId,userId,parentId,name,icon,sortOrder,isExpanded,isDeleted,deletedAt,createdAt,updatedAt) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`, Object.values(row)); + if (inserted.changes && enqueue) await this.enqueue(tx, "notebook", id, "upsert", row, undefined, "personal"); + return id; + } + + /** Personal root/folder documents use the existing notebook/note Sync V2 entities. */ + async createPersonalTreeNote(input: Partial & { id: string }, notebookId: string | null): Promise { + const result = await this.db.transaction(async (tx) => { + if (input.workspaceId) throw new Error("本机知识树创建仅支持个人空间"); + if (notebookId) { + const parent = (await tx.query<{ userId: string; isDeleted: number }>( + "SELECT userId,isDeleted FROM notebooks WHERE scopeKey='personal' AND id=?", [notebookId], + ))[0]; + if (!parent || parent.userId !== this.userId || parent.isDeleted) throw new Error("本机父文件夹不存在或不可写"); + } + const parent = notebookId || await this.ensurePersonalRootNotebook(tx, true); + const saved = await this.insertNote(tx, { ...input, workspaceId: null, notebookId: parent }, true); + // This records only locally created content, never inferred from a cached remote note. + await tx.run("INSERT INTO native_runtime_meta (key,value,updatedAt) VALUES (?,?,?)", + [`localTreeDraft:personal:${input.id}`, JSON.stringify(notebookId ? `notebook:${notebookId}` : null), saved.savedAt]); + return saved; + }); + notifyMobileSyncStatusChanged(); + return result; + } + + async listLocalTreeDrafts(): Promise> { + const rows = await this.db.query(`SELECT ${NOTE_LIST_METADATA_COLUMNS},'' AS contentText,m.value AS treeParent + FROM notes n JOIN native_runtime_meta m ON m.key='localTreeDraft:personal:' || n.id + WHERE n.scopeKey='personal' AND n.userId=? AND n.isTrashed=0`, [this.userId]); + return rows.map(({ treeParent, ...note }) => ({ ...note, treeParentId: JSON.parse(treeParent) as string | null })); + } + + /** Only call after live remote authorization; never enqueue another creation. */ + async hydrateAuthorizedNote(note: Note, notebook?: Notebook): Promise { + if (note.workspaceId || note.userId !== this.userId || !Number.isSafeInteger(note.version) || note.version < 1) { + throw new Error("远端笔记身份或空间校验失败"); + } + return this.db.transaction(async (tx) => { + const current = await this.getNote(note.id, tx, "personal"); + if (current) return current; + const blocked = (await tx.query(`SELECT entityId FROM sync_outbox WHERE scopeKey='personal' AND entityType='note' AND entityId=? + UNION ALL SELECT entityId FROM sync_conflicts WHERE scopeKey='personal' AND entityType='note' AND entityId=? AND status='unresolved' LIMIT 1`, [note.id, note.id])).length; + if (blocked) throw new Error("笔记在本机已删除或存在待处理修改,不能从远端重新创建"); + const parent = (await tx.query("SELECT id FROM notebooks WHERE scopeKey='personal' AND id=?", [note.notebookId]))[0]; + if (!parent) { + if (note.notebookId === `${ROOT_DOCUMENT_NOTEBOOK_PREFIX}personal:${this.userId}`) { + await this.ensurePersonalRootNotebook(tx, false); + } else { + if (!notebook || notebook.id !== note.notebookId || notebook.userId !== this.userId || notebook.workspaceId) { + throw new Error("远端笔记父文件夹身份校验失败"); + } + await tx.run(`INSERT INTO notebooks (id,scopeKey,workspaceId,userId,name,description,icon,color,sortOrder,isExpanded,createdAt,updatedAt) + VALUES (?,'personal',NULL,?,?,?,?,?,?,?,?,?)`, [notebook.id,this.userId,notebook.name,notebook.description ?? null, + notebook.icon || "📒",notebook.color ?? null,notebook.sortOrder || 0,notebook.isExpanded ?? 1,notebook.createdAt || now(),notebook.updatedAt || now()]); + } + } + await this.insertNote(tx, { ...note, workspaceId: null }, false); + return (await this.getNote(note.id, tx, "personal"))!; + }); } private async updateNote(id: string, patch: Partial): Promise { From 1b5b0c79610d93555904fa72e6e2836a19f26223 Mon Sep 17 00:00:00 2001 From: cropflre <767308865@qq.com> Date: Sun, 11 Oct 2026 15:56:19 +0800 Subject: [PATCH 02/13] test(sync): verify native tree creation and recovery on Android --- .github/workflows/android-local-first-ci.yml | 2 ++ CHANGELOG.md | 1 + backend/tests/sync-v2-android-server.ts | 3 ++ docs/sync-v2-native-tree-validation.md | 23 ++++++++++++- docs/sync-v2-reliability-validation.md | 2 ++ docs/sync-v2-reliability.en.md | 2 ++ .../sync-v2-native-tree-2026-10-11.json | 34 +++++++++++++++++++ frontend/tests/android-sync/main.ts | 34 +++++++++++++------ scripts/android-sync-acceptance.mjs | 5 +-- 9 files changed, 92 insertions(+), 14 deletions(-) create mode 100644 docs/test-results/sync-v2-native-tree-2026-10-11.json diff --git a/.github/workflows/android-local-first-ci.yml b/.github/workflows/android-local-first-ci.yml index 2cdd50cbf..ace084fd2 100644 --- a/.github/workflows/android-local-first-ci.yml +++ b/.github/workflows/android-local-first-ci.yml @@ -16,6 +16,7 @@ on: - "frontend/src/lib/accountLoginHistory.ts" - "frontend/src/components/AccountLoginHistory.tsx" - "frontend/src/lib/__tests__/mobileLocal*.test.ts" + - "frontend/src/lib/__tests__/mobileSignedInTreeCreate.test.ts" - "frontend/src/components/__tests__/AccountLoginHistory*.test.tsx" - ".github/workflows/android-local-first-ci.yml" pull_request: @@ -33,6 +34,7 @@ on: - "frontend/src/lib/accountLoginHistory.ts" - "frontend/src/components/AccountLoginHistory.tsx" - "frontend/src/lib/__tests__/mobileLocal*.test.ts" + - "frontend/src/lib/__tests__/mobileSignedInTreeCreate.test.ts" - "frontend/src/components/__tests__/AccountLoginHistory*.test.tsx" - ".github/workflows/android-local-first-ci.yml" workflow_dispatch: diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ded7f319..9413188f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ ## 未发布 / Unreleased +- **android**:个人知识树根级和普通文件夹内的 Markdown/富文本可先保存到本机并进入现有 Sync V2;将已授权的个人远端正文持久化,保护重启和后续离线编辑。Persist personal native tree documents and authorized missing bodies before subsequent offline edits; full shared/protected tree synchronization remains incomplete. - **sync**:保护 Native 离线编辑、删除冲突和迟到 ACK;重试保留 mutationId,避免旧更新复活已删除笔记。Protect durable native edits, deletion conflicts and late ACKs without changing mutation identity. - **android**:复用 WebSocket 唤醒 Sync V2 Pull,提交后刷新界面,并通过前台周期 Pull 补偿丢失通知。Wake native Pull on notices/reconnect and refresh UI after committed Apply. - **sync**:修复同页创建后删除、工作区 Snapshot 清理和冲突后继续编辑的数据保护问题。Protect pending descendants, attachment bytes and newer local edits during recovery. diff --git a/backend/tests/sync-v2-android-server.ts b/backend/tests/sync-v2-android-server.ts index 5e9dd8abd..23507e305 100644 --- a/backend/tests/sync-v2-android-server.ts +++ b/backend/tests/sync-v2-android-server.ts @@ -18,6 +18,7 @@ async function main() { const { attachRealtimeServer, broadcastToUser } = await import("../src/services/realtime"); const { setSyncBroadcaster } = await import("../src/sync/notify"); const { default: routes } = await import("../src/routes/sync-v2"); + const { default: treeRoutes } = await import("../src/routes/knowledge-tree"); const userId = "isolated-android-sync-user", db = getDb(); db.prepare("INSERT OR IGNORE INTO users (id,username,passwordHash,createdAt,updatedAt) VALUES (?,?,'test-only','now','now')").run(userId, userId); const fixtureToken = () => signLoginToken({ userId, username: userId, tokenVersion: 0 }); @@ -31,6 +32,8 @@ async function main() { await next(); }); app.route("/api/sync/v2", routes); + // Match index.ts's trailing-slash mount for the list entry point. + app.route("/api/knowledge-tree/", treeRoutes); app.get("/acceptance/note/:id", (c) => c.json(db.prepare("SELECT * FROM notes WHERE id=? AND userId=?").get(c.req.param("id"), userId) || null)); app.post("/acceptance/edit/:id", async (c) => { const note = db.prepare("SELECT * FROM notes WHERE id=? AND userId=?").get(c.req.param("id"), userId) as Record | undefined; diff --git a/docs/sync-v2-native-tree-validation.md b/docs/sync-v2-native-tree-validation.md index 2f69aed37..a80705940 100644 --- a/docs/sync-v2-native-tree-validation.md +++ b/docs/sync-v2-native-tree-validation.md @@ -26,6 +26,25 @@ Issue #829 的 Native 调用链存在可验证缺口:登录态知识树 `POST Windows 后端依赖是 Electron ABI,使用 `ELECTRON_RUN_AS_NODE=1` 的项目 Electron + tsx + `setup-db-isolation.ts`。初始 HTTP 用例在网络沙箱内失败,隔离回环测试经授权运行后通过;不是源码失败。Windows 契约 `--frontend` 入口存在既有 `spawnSync npx.cmd EINVAL`,已直接用 Node 24 执行其完整映射文件,未修改无关启动脚本。Vitest 的沙箱 realpath 限制同样通过批准的测试执行解决。 +## Android 新包证据 + +生产源码提交 `f6915749`。只读启动 `Pixel_8_API_35`,`emulator-5580`,API 35 / WebView 124.0.6367.219;独立包名 `com.nowen.note.syncacceptance`,JDK 21 的 `assembleDebug` 通过。APK SHA256 `0953a8e9eb2c3c40da3a4b5b2f26454f15162ed89c507340b40707e1dc6cbf72`。 + +这次测试页安装真实 `installMobileLocalFirstBridge`,通过 `knowledgeTreeApi.create` 创建两个根文档,通过 `api.getNote/updateNote` 读取和编辑。移除 adb reverse 确认 HTTP 服务器不可达 → 创建 MD/富文本并立即打开 → `am force-stop` → 离线重开同 ID、正文及待同步收据 → 恢复网络自动上传并记录 ACK:**通过**。实际 WS/Pull、双向更新、30 秒漏通知补偿、重连:**通过**。测试模拟器 native crash buffer 为 0 行;不等于已复现/修复原先报告的任意原生闪退。 + +各方向 30 次,计时包括 API 目录授权和轮询观察开销: + +| 回环链路 | P50 | P95 | P99(30 样本取最大值) | +| --- | ---: | ---: | ---: | +| HTTP Push → Native Pull 后通过笔记 API 读到正文 | 291ms | 304ms | 371ms | +| Native API 编辑 → 服务端正文及本机 ACK 落盘 | 447ms | 484ms | 529ms | + +机器记录:[本轮 Android report](test-results/sync-v2-native-tree-2026-10-11.json)。没有完整 EditorPane GUI、密码/共享/跨文档树结构、附件 bytes、生产 JWT 全套中间件或公网网络验收;仍不是手机浏览器证据。连接的 PJZ110 未参与本轮安装与测试。 + +失败及保留日志:第一次恢复网络时 helper 的树入口少了正式 `index.ts` 使用的尾斜杠挂载,404;修正后第二次因测试页自动恢复旧令牌、driver 又写入新令牌,触发正确的 `AUTH_SESSION_CHANGED` 保护。去掉测试页自动会话恢复,由 driver 每次明确初始化后通过。原测试库、正文及队列保留,无清库、无放松生产会话或 ACK 检查。忽略的 `sync-tree-device-failed404.log`、`sync-tree-device-failed-session.log` 保留失败记录。测试后已关闭 helper/只读模拟器并恢复生成的 Gradle 文件。 + +重跑命令与上一轮一致:Vite `--config vite.android-sync.config.ts` → Capacitor sync → `gradlew -I :app:assembleDebug` → 隔离设备安装 → `node scripts/android-sync-acceptance.mjs emulator-5580`。正常前端构建不包含测试入口;helper 只监听 127.0.0.1。 + ## 数据安全与兼容性 - 业务、创建来源、Outbox 同事务成功才返回;任一插入失败全部回滚。既有保存检查保持开启。 @@ -38,7 +57,7 @@ Windows 后端依赖是 Electron ABI,使用 `ELECTRON_RUN_AS_NODE=1` 的项目 | 能力 | 本轮范围 | 剩余限制 | | --- | --- | --- | -| Native 个人根级 Markdown/富文本离线创建 | 已实现、独立 SQLite 回归通过 | Android 新包证据见后续记录;完整 EditorPane 视觉/编辑生命周期单独验收 | +| Native 个人根级 Markdown/富文本离线创建 | 已实现、独立 SQLite 与 Android 新包 API Bridge 验收通过 | 完整 EditorPane 视觉/编辑生命周期单独验收 | | Native 普通个人文件夹内离线创建 | 已实现、Bridge 回归通过 | 父文件夹须已同步,真实服务端树/密码元数据已缓存 | | #829 个人已授权正文持久化 | 已实现、回归通过 | 手机浏览器、共享/工作区正文与完整结构同步未完成;不关闭 Issue | | Native 文件夹创建、任意节点移动/排序/删除冲突 | 既有远端路径 | 完整 Native `knowledge_tree_node` 能力继续下一批 | @@ -46,3 +65,5 @@ Windows 后端依赖是 Electron ABI,使用 `ELECTRON_RUN_AS_NODE=1` 的项目 | Web、iOS、HarmonyOS、Docker/NAS、公网延迟 | 本轮无新增验收 | 沿用上一轮未完成清单,不能套用 Native/回环结论 | English: Personal native Markdown/rich-text root and ordinary-folder creation now commits content, provenance and the existing Sync V2 Outbox atomically. A live-authorized personal body missing locally is hydrated without a duplicate creation, with local edits/deletes and session/password guards preserved. This is a bounded legacy-entity bridge, not complete native tree-structure synchronization or mobile-browser acceptance. + +Android API 35 emulator acceptance now exercises the actual personal knowledge-tree create and note API bridges, including unreachable-network creation, force-stop/reopen and automatic ACK recovery. Thirty loopback samples per direction measured P50/P95/P99 291/304/371ms for remote Push to native API read, and 447/484/529ms for native API editing to server content plus persisted ACK. Full editor GUI, protected/shared tree operations, attachment bytes and WAN acceptance remain pending. diff --git a/docs/sync-v2-reliability-validation.md b/docs/sync-v2-reliability-validation.md index cc9021d2f..2b7a5c039 100644 --- a/docs/sync-v2-reliability-validation.md +++ b/docs/sync-v2-reliability-validation.md @@ -1,5 +1,7 @@ # Sync V2 reliability validation / 同步可靠性验证 +第二批更新:2026-10-11 的 Native 个人根级/普通文件夹 MD/富文本创建、已授权个人正文持久化,以及新包 API Bridge 强杀恢复证据见 [Native tree validation](sync-v2-native-tree-validation.md)。下文保留第一批的原始结果与当时缺口;完整 Native 树结构和浏览器 Local-first 仍未完成。 + 日期:2026-10-11。基准 `d95d3787`;开发分支 `codex/sync-v2-reliability`。所有笔记正文均为临时测试数据。 ## Phase 0 diff --git a/docs/sync-v2-reliability.en.md b/docs/sync-v2-reliability.en.md index a72e3dd02..efab0d419 100644 --- a/docs/sync-v2-reliability.en.md +++ b/docs/sync-v2-reliability.en.md @@ -25,6 +25,8 @@ In an adb loopback environment, 30 samples per direction measured P50/P95/P99 of ## Remaining acceptance blockers +Second-batch evidence: [Native personal tree validation](sync-v2-native-tree-validation.md) adds atomic personal root/ordinary-folder Markdown and rich-text creation, durable hydration of authorized personal bodies, and actual Android API-bridge force-stop/recovery acceptance. The following paragraph records the first batch's original gaps; full native tree structure and mobile-browser Local-first remain incomplete. + Web/mobile browsers remain REST plus IndexedDB cache/offline queue, without unified transactional Local-first storage. Signed-in Android knowledge tree operations remain server-first; Native knowledge-tree V2 is missing. Issue #829's existing authorized remote fallback is not proof that tree-created notes are durably readable offline. Full EditorPane, tree creation, conflict UX, attachment bytes and revocation need device acceptance. Parent deletion with protected descendants, no-base stale IDs/tombstones, long-running WAN latency and crash reproduction need further tests. Docker full-sync/NAS and macOS/Linux/iOS/HarmonyOS package acceptance have not run. Commits: audit `bd26fabe`, durability fixes `421d37eb`, native realtime/recovery `4588de41`. No main modification, force-push, merge or release was performed. The [Android harness](../scripts/android-sync-acceptance.mjs), test-only Vite configuration and loopback backend helper are excluded from normal production builds. diff --git a/docs/test-results/sync-v2-native-tree-2026-10-11.json b/docs/test-results/sync-v2-native-tree-2026-10-11.json new file mode 100644 index 000000000..adf5c65eb --- /dev/null +++ b/docs/test-results/sync-v2-native-tree-2026-10-11.json @@ -0,0 +1,34 @@ +{ + "device": "emulator-5580", + "package": "com.nowen.note.syncacceptance", + "offlineImmediateRead": true, + "forceStopRecovery": true, + "knowledgeTreeRootCreate": true, + "readWriteThroughProductionApiBridge": true, + "offlineNetworkIsolation": "adb reverse removed; HTTP server unreachable from Android", + "markdownAndRichText": true, + "missingNoticePoll": true, + "reconnect": true, + "httpPushToNativePull": { + "samples": 30, + "p50Ms": 291, + "p95Ms": 304, + "p99Ms": 371 + }, + "nativeCommitToServerAndAck": { + "samples": 30, + "p50Ms": 447, + "p95Ms": 484, + "p99Ms": 529 + }, + "errors": [ + { + "lastError": null + } + ], + "boundary": "Loopback adb reverse; real Android Native SQLite, signed-in personal knowledgeTreeApi create, api get/update and MobileSyncEngine. Does not test full EditorPane, protected/shared/cross-type tree mutations, attachment bytes, or production JWT middleware.", + "apiLevel": "35", + "productionSourceCommit": "f6915749", + "apkSha256": "0953a8e9eb2c3c40da3a4b5b2f26454f15162ed89c507340b40707e1dc6cbf72", + "nativeCrashBufferLines": 0 +} diff --git a/frontend/tests/android-sync/main.ts b/frontend/tests/android-sync/main.ts index 540b447c7..94bc5b087 100644 --- a/frontend/tests/android-sync/main.ts +++ b/frontend/tests/android-sync/main.ts @@ -7,12 +7,21 @@ import { installMobileSyncRealtime } from "../../src/lib/mobileSyncRealtime"; import { setMobileSyncEnabled } from "../../src/lib/mobileSyncStatus"; import { readNativeNoteSyncReceipt } from "../../src/lib/mobileNoteSyncReceipt"; import { realtime } from "../../src/lib/realtime"; +import { api, setCurrentWorkspace } from "../../src/lib/api"; +import { knowledgeTreeApi } from "../../src/lib/knowledgeTreeApi"; +import { installMobileLocalFirstBridge } from "../../src/lib/mobileLocalFirstBridge"; type Config = { serverUrl: string; token: string; userId: string }; +let initialized: Promise | undefined; async function initialize(config: Config) { localStorage.setItem("sync-acceptance-config", JSON.stringify(config)); localStorage.setItem("nowen-token", config.token); localStorage.setItem("nowen-server-url", config.serverUrl); + initialized ||= initializeOnce(config); + return initialized; +} +async function initializeOnce(config: Config) { + setCurrentWorkspace("personal"); const profileId = "isolated-acceptance", deviceId = "android-acceptance"; const accountId = `sync-acceptance:${config.userId}`; const db = await openNativeDatabase(accountId); @@ -23,6 +32,7 @@ async function initialize(config: Config) { const engine = new MobileSyncEngine({ db, attachments, profileId, deviceId, ...config }); const repository = new NativeLocalRepository({ db, attachments, accountId, userId: config.userId, getScopeKey: () => "personal", requestSync: () => engine.requestSync(150) }); + installMobileLocalFirstBridge(repository, db, config.userId); let dispose: (() => void) | undefined; const ids = async () => { const row = (await db.query<{value:string}>("SELECT value FROM native_runtime_meta WHERE key='acceptance-note-ids'"))[0]; @@ -30,21 +40,23 @@ async function initialize(config: Config) { }; const acceptance = { async createOffline() { - const book = crypto.randomUUID(), notes = [crypto.randomUUID(), crypto.randomUUID()]; - await repository.notebooks.create({ id: book, name: "Acceptance" }); - for (const [index, id] of notes.entries()) await repository.notes.create({ id, notebookId: book, - contentFormat: index ? "tiptap-json" : "markdown", - content: index ? JSON.stringify({ type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "offline-rich" }] }] }) : "offline-markdown", - contentText: index ? "offline-rich" : "offline-markdown" }); + const notes: string[] = []; + for (const [index, nodeType] of (["markdown", "note"] as const).entries()) { + const node = await knowledgeTreeApi.create({ parentId: null, nodeType, title: "Acceptance" }); + notes.push(node.resourceId); + await api.updateNote(node.resourceId, { contentText: index ? "offline-rich" : "offline-markdown", + content: index ? JSON.stringify({ type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "offline-rich" }] }] }) : "offline-markdown" }); + await api.getNote(node.resourceId); + } await db.run("INSERT INTO native_runtime_meta (key,value,updatedAt) VALUES ('acceptance-note-ids',?,'now') ON CONFLICT(key) DO UPDATE SET value=excluded.value", [JSON.stringify(notes)]); return notes; }, async status() { - return Promise.all((await ids()).map(async (id) => ({ id, note: await repository.notes.get(id), receipt: await readNativeNoteSyncReceipt(db, profileId, id) }))); + return Promise.all((await ids()).map(async (id) => ({ id, note: await api.getNote(id), receipt: await readNativeNoteSyncReceipt(db, profileId, id) }))); }, async edit(id: string, text: string) { - const note = await repository.notes.get(id); - await repository.notes.update(id, { contentText: text, content: note?.contentFormat === "markdown" ? text : JSON.stringify({ type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text }] }] }) }); + const note = await api.getNote(id); + await api.updateNote(id, { contentText: text, content: note?.contentFormat === "markdown" ? text : JSON.stringify({ type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text }] }] }) }); }, start() { setMobileSyncEnabled(true); dispose = installMobileSyncRealtime(engine); engine.start(); }, stop() { dispose?.(); dispose = undefined; engine.stop(); setMobileSyncEnabled(false); realtime.disconnect(); }, @@ -55,5 +67,5 @@ async function initialize(config: Config) { document.getElementById("status")!.textContent = "Native SQLite ready. Test account only."; } Object.assign(window, { initializeAcceptance: initialize }); -const saved = localStorage.getItem("sync-acceptance-config"); -if (saved) void initialize(JSON.parse(saved)).catch(() => { document.getElementById("status")!.textContent = "Native initialization failed"; }); +// The driver explicitly supplies one current fixture session after each reload. +// Auto-restoring an old token before that call would bind the engine to it. diff --git a/scripts/android-sync-acceptance.mjs b/scripts/android-sync-acceptance.mjs index 59c2e37c0..a382f8fdd 100644 --- a/scripts/android-sync-acceptance.mjs +++ b/scripts/android-sync-acceptance.mjs @@ -90,12 +90,13 @@ try { await native("start"); await wait(async () => (await native("status"))[0].note.contentText === "reconnected", "reconnect did not catch up"); const report = { device: serial, package: "com.nowen.note.syncacceptance", offlineImmediateRead: true, forceStopRecovery: true, + knowledgeTreeRootCreate: true, readWriteThroughProductionApiBridge: true, offlineNetworkIsolation: "adb reverse removed; HTTP server unreachable from Android", markdownAndRichText: true, missingNoticePoll: true, reconnect: true, httpPushToNativePull: stats(download), nativeCommitToServerAndAck: stats(upload), errors: await native("errors"), - boundary: "Loopback adb reverse; real Android native SQLite and MobileSyncEngine. Does not test full EditorPane, knowledge tree creation, attachment bytes, or production JWT middleware." }; + boundary: "Loopback adb reverse; real Android Native SQLite, signed-in personal knowledgeTreeApi create, api get/update and MobileSyncEngine. Does not test full EditorPane, protected/shared/cross-type tree mutations, attachment bytes, or production JWT middleware." }; fs.mkdirSync(new URL("../docs/test-results/", import.meta.url), { recursive: true }); - fs.writeFileSync(new URL("../docs/test-results/sync-v2-android-2026-10-11.json", import.meta.url), JSON.stringify(report, null, 2)); + fs.writeFileSync(new URL("../docs/test-results/sync-v2-native-tree-2026-10-11.json", import.meta.url), JSON.stringify(report, null, 2)); console.log(JSON.stringify(report, null, 2)); } finally { if (page) await native("stop").catch(() => undefined); From a732b22eca0f2cc8652c1b22df948a6bf7207166 Mon Sep 17 00:00:00 2001 From: cropflre <767308865@qq.com> Date: Sun, 11 Oct 2026 16:18:19 +0800 Subject: [PATCH 03/13] feat(sync): receive negotiated personal tree structure on native clients --- .github/workflows/android-local-first-ci.yml | 10 ++ .../data-consistency-contract-ci.yml | 6 + backend/tests/sync-v2-android-server.ts | 9 ++ .../tests/sync-v2-mobile-multidevice.test.ts | 130 ++++++++++++++++++ .../data-consistency-contract.matrix.json | 14 +- .../mobileNativeTreeSubscription.test.ts | 67 +++++++++ .../lib/__tests__/mobileSyncSwitch.test.ts | 1 + .../__tests__/mobileSyncUnknownEntity.test.ts | 6 +- .../__tests__/voiceMemoLocalFirst.test.tsx | 2 +- .../nativeDatabaseInitialization.test.ts | 29 +++- frontend/src/lib/mobileSyncEngine.ts | 107 +++++++++++--- frontend/src/lib/nativeDatabase.ts | 32 ++++- frontend/src/lib/nativeKnowledgeTreeSync.ts | 85 ++++++++++++ frontend/tests/android-sync/main.ts | 4 + scripts/android-sync-acceptance.mjs | 22 ++- 15 files changed, 488 insertions(+), 36 deletions(-) create mode 100644 frontend/src/lib/__tests__/mobileNativeTreeSubscription.test.ts create mode 100644 frontend/src/lib/nativeKnowledgeTreeSync.ts diff --git a/.github/workflows/android-local-first-ci.yml b/.github/workflows/android-local-first-ci.yml index ace084fd2..7ca2cd7d3 100644 --- a/.github/workflows/android-local-first-ci.yml +++ b/.github/workflows/android-local-first-ci.yml @@ -10,6 +10,10 @@ on: - "frontend/src/lib/mobileLocal*.ts" - "frontend/src/lib/realtime.ts" - "frontend/src/lib/mobileSyncEngine.ts" + - "frontend/src/lib/nativeKnowledgeTreeSync.ts" + - "frontend/src/lib/nativeDatabase.ts" + - "frontend/src/lib/__tests__/mobileNativeTreeSubscription.test.ts" + - "frontend/src/lib/encryptedNotes/__tests__/nativeDatabaseInitialization.test.ts" - "frontend/src/lib/__tests__/mobileSyncUnexpectedFailure.test.ts" - "frontend/src/lib/__tests__/mobileSyncEndpoint.test.ts" - "frontend/src/App.tsx" @@ -28,6 +32,10 @@ on: - "frontend/src/lib/mobileLocal*.ts" - "frontend/src/lib/realtime.ts" - "frontend/src/lib/mobileSyncEngine.ts" + - "frontend/src/lib/nativeKnowledgeTreeSync.ts" + - "frontend/src/lib/nativeDatabase.ts" + - "frontend/src/lib/__tests__/mobileNativeTreeSubscription.test.ts" + - "frontend/src/lib/encryptedNotes/__tests__/nativeDatabaseInitialization.test.ts" - "frontend/src/lib/__tests__/mobileSyncUnexpectedFailure.test.ts" - "frontend/src/lib/__tests__/mobileSyncEndpoint.test.ts" - "frontend/src/App.tsx" @@ -78,5 +86,7 @@ jobs: src/lib/__tests__/mobileLocalRealtimeGuard.test.ts src/lib/__tests__/mobileSyncUnexpectedFailure.test.ts src/lib/__tests__/mobileSyncEndpoint.test.ts + src/lib/__tests__/mobileNativeTreeSubscription.test.ts + src/lib/encryptedNotes/__tests__/nativeDatabaseInitialization.test.ts src/components/__tests__/AccountLoginHistory.test.tsx src/components/__tests__/AccountLoginHistoryServerEdit.test.tsx diff --git a/.github/workflows/data-consistency-contract-ci.yml b/.github/workflows/data-consistency-contract-ci.yml index 59076aeb0..0aa1f217c 100644 --- a/.github/workflows/data-consistency-contract-ci.yml +++ b/.github/workflows/data-consistency-contract-ci.yml @@ -27,6 +27,9 @@ on: - "frontend/src/i18n/coverageTranslations.ts" - "frontend/src/lib/mobileLocalFirst*" - "frontend/src/lib/nativeLocal*" + - "frontend/src/lib/nativeKnowledgeTreeSync.ts" + - "frontend/src/lib/nativeDatabase.ts" + - "frontend/src/lib/__tests__/mobileNativeTreeSubscription.test.ts" - "frontend/src/lib/offline*" - "frontend/src/lib/noteSyncSafety*" - "frontend/src/lib/syncEngine*" @@ -65,6 +68,9 @@ on: - "frontend/src/i18n/coverageTranslations.ts" - "frontend/src/lib/mobileLocalFirst*" - "frontend/src/lib/nativeLocal*" + - "frontend/src/lib/nativeKnowledgeTreeSync.ts" + - "frontend/src/lib/nativeDatabase.ts" + - "frontend/src/lib/__tests__/mobileNativeTreeSubscription.test.ts" - "frontend/src/lib/offline*" - "frontend/src/lib/noteSyncSafety*" - "frontend/src/lib/syncEngine*" diff --git a/backend/tests/sync-v2-android-server.ts b/backend/tests/sync-v2-android-server.ts index 23507e305..91bcbc9ec 100644 --- a/backend/tests/sync-v2-android-server.ts +++ b/backend/tests/sync-v2-android-server.ts @@ -35,6 +35,15 @@ async function main() { // Match index.ts's trailing-slash mount for the list entry point. app.route("/api/knowledge-tree/", treeRoutes); app.get("/acceptance/note/:id", (c) => c.json(db.prepare("SELECT * FROM notes WHERE id=? AND userId=?").get(c.req.param("id"), userId) || null)); + app.post("/acceptance/tree-folder", async (c) => { + const {parentId} = await c.req.json<{parentId:string}>(); + return app.request("http://localhost/api/knowledge-tree/nodes?workspaceId=personal", {method:"POST", + headers:{Authorization:`Bearer ${fixtureToken()}`,"Content-Type":"application/json"}, + body:JSON.stringify({parentId,nodeType:"folder",title:"Acceptance structure"})}); + }); + app.put("/acceptance/tree-move/:id", async (c) => app.request( + `http://localhost/api/knowledge-tree/nodes/${encodeURIComponent(c.req.param("id"))}/move`, {method:"PUT", + headers:{Authorization:`Bearer ${fixtureToken()}`,"Content-Type":"application/json"},body:JSON.stringify(await c.req.json())})); app.post("/acceptance/edit/:id", async (c) => { const note = db.prepare("SELECT * FROM notes WHERE id=? AND userId=?").get(c.req.param("id"), userId) as Record | undefined; if (!note) return c.json({ error: "NOT_FOUND" }, 404); diff --git a/backend/tests/sync-v2-mobile-multidevice.test.ts b/backend/tests/sync-v2-mobile-multidevice.test.ts index 79de48d8d..5a7d8b07b 100644 --- a/backend/tests/sync-v2-mobile-multidevice.test.ts +++ b/backend/tests/sync-v2-mobile-multidevice.test.ts @@ -11,6 +11,7 @@ import { NativeLocalRepository } from "../../frontend/src/lib/nativeLocalReposit import { MobileSyncEngine } from "../../frontend/src/lib/mobileSyncEngine"; import type { NativeDatabase } from "../../frontend/src/lib/nativeDatabase"; import { nativeReceiptKey, readNativeNoteSyncReceipt } from "../../frontend/src/lib/mobileNoteSyncReceipt"; +import { applyNativePersonalTree } from "../../frontend/src/lib/nativeKnowledgeTreeSync"; const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nowen-sync-multidevice-")); process.env.DB_PATH = path.join(tmpDir, "server.db"); @@ -26,6 +27,7 @@ let httpServer: ReturnType; let serverUrl: string; const clients: Client[] = []; let afterPush: ((response: Response) => Promise) | undefined; +let afterSnapshot: ((response: Response) => Promise) | undefined; // The real native schema, with independent on-disk SQLite databases. The adapter // serializes transactions just like NativeDatabaseImpl; no shared client state. @@ -102,6 +104,7 @@ test.before(async () => { c.req.raw.headers.set("X-User-Id", c.req.header("Authorization")?.replace("Bearer ", "") || ""); await next(); if (c.req.path.endsWith("/push") && afterPush) c.res = await afterPush(c.res); + if (c.req.path.endsWith("/snapshot") && afterSnapshot) c.res = await afterSnapshot(c.res); }); app.route("/api/sync/v2", routes.default); httpServer = serve({ fetch: app.fetch, hostname: "127.0.0.1", port: 0 }); @@ -138,6 +141,120 @@ test("personal tree creates are readable offline, survive reopen and converge wi } }); +test("negotiated native tree preserves folder-under-document links and remote moves across reopen", async () => { + const a = await client("structure-writer"), b = await client("structure-reader"); + const book = randomUUID(), note = randomUUID(); + await a.repository.notebooks.create({ id: book, name: "Tree folder" }); + await a.repository.createPersonalTreeNote({ id: note, contentFormat: "markdown", content: "Tree body" }, null); + await a.sync(); + serverDb.prepare("UPDATE knowledge_tree_nodes SET parentId=?,sortOrder=17 WHERE id=?") + .run(`note:${note}`, `notebook:${book}`); + await b.sync(); + const row = (await b.db.query<{ parentId: string; sortOrder: number }>("SELECT parentId,sortOrder FROM native_knowledge_tree_nodes WHERE scopeKey='personal' AND id=?", [`notebook:${book}`]))[0]; + assert.deepEqual(row, { parentId: `note:${note}`, sortOrder: 17 }); + assert.equal((await b.repository.notebooks.get(book))?.parentId, null, "legacy resource parent does not flatten the tree"); + assert.equal((await b.repository.notes.get(note))?.content, "Tree body"); + const binding = serverDb.prepare("SELECT entitySet FROM sync_v2_clients WHERE deviceId=? AND userId=? AND scopeKey='personal'").get("structure-reader", USER) as { entitySet: string }; + assert.ok(binding.entitySet.includes("knowledge_tree_node")); + await b.db.close(); + const reopened = await client("structure-reader"); + assert.equal((await reopened.db.query<{parentId:string}>("SELECT parentId FROM native_knowledge_tree_nodes WHERE id=?", [`notebook:${book}`]))[0].parentId, `note:${note}`); + serverDb.prepare("UPDATE knowledge_tree_nodes SET parentId=NULL,sortOrder=18 WHERE id=?").run(`notebook:${book}`); + await reopened.sync(); + assert.deepEqual((await reopened.db.query("SELECT parentId,sortOrder FROM native_knowledge_tree_nodes WHERE id=?", [`notebook:${book}`]))[0], { parentId: null, sortOrder: 18 }); +}); + +test("a new native tree subscription rebuilds an existing legacy cursor without discarding local edits", async () => { + const c = await client("structure-upgrade"), book = randomUUID(), note = randomUUID(); + await c.repository.notebooks.create({ id: book, name: "Upgrade folder" }); + await c.repository.createPersonalTreeNote({ id: note, contentFormat: "markdown", content: "Pending body" }, null); + await c.db.run("INSERT INTO sync_state (profileId,scopeKey,lastSequence,accessStatus,accessFingerprint) VALUES (?,'personal',999999,'active','personal:owner')", [c.profileId]); + await c.sync(); + assert.equal((await pending(c)).length, 0); + assert.equal((await c.repository.notes.get(note))?.content, "Pending body"); + assert.ok((await c.db.query("SELECT id FROM native_knowledge_tree_nodes WHERE id=?", [`notebook:${book}`])).length); + const state = (await c.db.query<{lastSequence:number;lastError:string|null}>("SELECT lastSequence,lastError FROM sync_state WHERE profileId=?", [c.profileId]))[0]; + assert.equal(state.lastError, null); + assert.ok(state.lastSequence < 999999, "the new entity set must not inherit the legacy high-water mark"); +}); + +test("invalid remote tree rolls back incremental bodies and structure without acknowledging the cursor", async () => { + const c = await client("structure-invalid"), note = randomUUID(); + await c.repository.createPersonalTreeNote({ id: note, contentFormat: "markdown", content: "Before invalid tree" }, null); + await c.sync(); + const checkpoint = (await c.db.query<{lastSequence:number}>("SELECT lastSequence FROM sync_state WHERE profileId=?", [c.profileId]))[0].lastSequence; + const original = (await c.db.query("SELECT * FROM native_knowledge_tree_nodes WHERE id=?", [`note:${note}`]))[0]; + serverDb.prepare("UPDATE notes SET content='After remote edit',version=version+1 WHERE id=?").run(note); + serverDb.prepare("UPDATE knowledge_tree_nodes SET sortOrder=sortOrder+1 WHERE id=?").run(`note:${note}`); + afterSnapshot = async (response) => { + const body = await response.json() as {items:Array<{entityId:string;entityType:string;payload:Record}>}; + for (const item of body.items || []) if (item.entityType === "knowledge_tree_node" && item.entityId === `note:${note}`) item.payload.parentId = item.entityId; + return Response.json(body, {status:response.status}); + }; + try { await c.sync(); } finally { afterSnapshot = undefined; } + assert.equal((await c.repository.notes.get(note))?.content, "Before invalid tree"); + assert.deepEqual((await c.db.query("SELECT * FROM native_knowledge_tree_nodes WHERE id=?", [`note:${note}`]))[0], original); + const state = (await c.db.query<{lastSequence:number;lastError:string}>("SELECT lastSequence,lastError FROM sync_state WHERE profileId=?", [c.profileId]))[0]; + assert.equal(state.lastSequence, checkpoint); assert.equal(state.lastError, "INVALID_TREE_STRUCTURE"); + assert.equal((serverDb.prepare("SELECT lastSequence FROM sync_v2_clients WHERE deviceId=? AND userId=? AND scopeKey='personal'").get("structure-invalid", USER) as {lastSequence:number}).lastSequence, checkpoint); + await c.sync(); + assert.equal((await c.repository.notes.get(note))?.content, "After remote edit"); +}); + +test("native structural Apply rejects foreign scopes, identities and missing dependencies without altering Outbox", async () => { + const c = await client("structure-validation"), id = randomUUID(); + await c.repository.notebooks.create({ id, name: "Validation" }); + const entry = { entityId:`notebook:${id}`, payload: { id:`notebook:${id}`, userId:USER, workspaceId:null, + parentId:null, nodeType:"folder", resourceType:"notebook", resourceId:id, sortOrder:0, isDeleted:0, deletedAt:null, createdAt:"now", updatedAt:"now" } }; + await c.db.transaction((tx) => applyNativePersonalTree(tx,[entry],USER,true)); + const queued = await pending(c); + for (const patch of [{userId:"other-user"}, {workspaceId:"foreign"}, {scopeKey:"workspace:foreign"}, {resourceId:"missing"}, {parentId:"notebook:missing"}, {sortOrder:-1}, {sortOrder:1.5}, {nodeType:"file"}, {parentId:entry.entityId}]) { + await assert.rejects(c.db.transaction((tx) => applyNativePersonalTree(tx,[{...entry,payload:{...entry.payload,...patch}}],USER,true)), {code:"INVALID_TREE_STRUCTURE"}); + assert.deepEqual((await c.db.query("SELECT parentId,sortOrder FROM native_knowledge_tree_nodes WHERE id=?", [entry.entityId]))[0], {parentId:null,sortOrder:0}); + assert.deepEqual(await pending(c), queued); + } +}); + +test("native file tree metadata requires its scoped attachment without claiming bytes or authorization", async () => { + const c = await client("structure-file"), note = randomUUID(), file = randomUUID(); + await c.repository.createPersonalTreeNote({ id:note, contentFormat:"markdown", content:"Attachment owner" },null); + const root = {entityId:`note:${note}`,payload:{id:`note:${note}`,userId:USER,workspaceId:null,parentId:null,nodeType:"markdown",resourceType:"note",resourceId:note,sortOrder:0,isDeleted:0,deletedAt:null,createdAt:"now",updatedAt:"now"}}; + const entry = {entityId:`file:${file}`,payload:{...root.payload,id:`file:${file}`,parentId:root.entityId,nodeType:"file",resourceType:"file",resourceId:file}}; + await assert.rejects(c.db.transaction((tx) => applyNativePersonalTree(tx,[root,entry],USER,true)), {code:"INVALID_TREE_STRUCTURE"}); + await c.db.run(`INSERT INTO attachments (id,scopeKey,workspaceId,noteId,userId,filename,mimeType,size,available,transferStatus,createdAt,updatedAt) + VALUES (?,'personal',NULL,?,?,'fixture.txt','text/plain',1,0,'remote','now','now')`, [file,note,USER]); + const queued = await pending(c); + await c.db.transaction((tx) => applyNativePersonalTree(tx,[root,entry],USER,true)); + assert.equal((await c.db.query<{parentId:string}>("SELECT parentId FROM native_knowledge_tree_nodes WHERE id=?", [entry.entityId]))[0].parentId, root.entityId); + assert.equal((await c.db.query<{available:number}>("SELECT available FROM attachments WHERE id=?", [file]))[0].available, 0); + assert.deepEqual(await pending(c), queued); + assert.equal((await c.db.query("SELECT key FROM native_runtime_meta WHERE key LIKE 'knowledgeTreeSnapshot:%'")).length, 0); +}); + +test("native structural Apply validates final ancestry and deletes parent/child in either order", async () => { + const c = await client("structure-reversal"), a = randomUUID(), b = randomUUID(); + for (const id of [a,b]) await c.repository.notebooks.create({ id, name:"Reversal" }); + const entry = (id:string,parentId:string|null) => ({entityId:`notebook:${id}`,payload:{id:`notebook:${id}`,userId:USER,workspaceId:null,parentId, + nodeType:"folder",resourceType:"notebook",resourceId:id,sortOrder:0,isDeleted:0,deletedAt:null,createdAt:"now",updatedAt:"now"}}); + await c.db.transaction((tx) => applyNativePersonalTree(tx,[entry(a,`notebook:${b}`),entry(b,null)],USER,true)); + await c.db.transaction((tx) => applyNativePersonalTree(tx,[entry(b,`notebook:${a}`),entry(a,null)],USER,false)); + await assert.rejects(c.db.transaction((tx) => applyNativePersonalTree(tx,[{entityId:`notebook:${a}`,payload:{__delete:true}}],USER,false)), {code:"INVALID_TREE_STRUCTURE"}); + assert.equal((await c.db.query("SELECT id FROM native_knowledge_tree_nodes")).length, 2); + await c.db.transaction((tx) => applyNativePersonalTree(tx,[a,b].map((id) => ({entityId:`notebook:${id}`,payload:{__delete:true}})),USER,false)); + assert.equal((await c.db.query("SELECT id FROM native_knowledge_tree_nodes")).length, 0); + assert.equal((await pending(c)).length, 2, "structural deletes do not delete business rows or queued changes"); +}); + +test("native tree Snapshot reconciles stale metadata after remote permanent deletion", async () => { + const c = await client("structure-prune"), note = randomUUID(); + await c.repository.createPersonalTreeNote({ id:note, contentFormat:"markdown", content:"Deleted remote root" },null); + await c.sync(); + serverDb.prepare("DELETE FROM notes WHERE id=?").run(note); + await c.db.run("UPDATE sync_state SET accessStatus='replan_required' WHERE profileId=?", [c.profileId]); + await c.sync(); + assert.equal((await c.db.query("SELECT id FROM native_knowledge_tree_nodes WHERE id=?", [`note:${note}`])).length, 0); +}); + test("tree create rolls back body, root container and provenance when Outbox insertion fails", async () => { const c = await client("tree-rollback"), id = randomUUID(); await c.db.run("CREATE TRIGGER reject_tree_outbox BEFORE INSERT ON sync_outbox BEGIN SELECT RAISE(ABORT,'fixture outbox failure'); END"); @@ -425,3 +542,16 @@ test("a failed Outbox insertion rolls back note creation in the same SQLite tran await a.repository.notes.create({ id, notebookId: book, content: "saved after retry", contentFormat: "markdown" }); assert.equal((await a.repository.notes.get(id))?.content, "saved after retry"); }); + +test("native tree subscription completes multiple Snapshot pages before binding and survives incremental deletion", async () => { + const c = await client("structure-pagination"), ids = Array.from({length:230},() => randomUUID()); + const insert = serverDb.prepare("INSERT INTO notebooks (id,userId,workspaceId,parentId,name,createdAt,updatedAt) VALUES (?,?,NULL,NULL,'Paged tree','now','now')"); + serverDb.transaction(() => { for (const id of ids) insert.run(id,USER); })(); + await c.sync(); + const rows = await c.db.query<{id:string}>("SELECT id FROM native_knowledge_tree_nodes"); + for (const id of ids) assert.ok(rows.some((row) => row.id === `notebook:${id}`)); + assert.equal((await c.db.query<{lastError:string|null}>("SELECT lastError FROM sync_state WHERE profileId=?", [c.profileId]))[0].lastError, null); + serverDb.prepare("DELETE FROM notebooks WHERE id=?").run(ids[0]); + await c.sync(); + assert.equal((await c.db.query("SELECT id FROM native_knowledge_tree_nodes WHERE id=?", [`notebook:${ids[0]}`])).length, 0); +}); diff --git a/docs/architecture/data-consistency-contract.matrix.json b/docs/architecture/data-consistency-contract.matrix.json index 4ddf670e5..9c7029fba 100644 --- a/docs/architecture/data-consistency-contract.matrix.json +++ b/docs/architecture/data-consistency-contract.matrix.json @@ -282,7 +282,19 @@ { "file": "frontend/src/lib/__tests__/mobileSignedInTreeCreate.test.ts", "case": "retains server checks for protected folders and workspaces" }, { "file": "frontend/src/lib/__tests__/mobileSignedInTreeCreate.test.ts", "case": "does not project an ACKed note omitted by the live authorization tree" }, { "file": "frontend/src/lib/__tests__/mobileLocalFirstPasswordAccess.test.ts", "case": "reads a locally created root with live server proofs" }, - { "file": "frontend/src/lib/__tests__/mobileLocalFirstPasswordAccess.test.ts", "case": "does not persist or return a remote body after its native session is disposed" } + { "file": "frontend/src/lib/__tests__/mobileLocalFirstPasswordAccess.test.ts", "case": "does not persist or return a remote body after its native session is disposed" }, + { "file": "backend/tests/sync-v2-mobile-multidevice.test.ts", "case": "negotiated native tree preserves folder-under-document links" }, + { "file": "backend/tests/sync-v2-mobile-multidevice.test.ts", "case": "a new native tree subscription rebuilds an existing legacy cursor" }, + { "file": "backend/tests/sync-v2-mobile-multidevice.test.ts", "case": "invalid remote tree rolls back incremental bodies and structure" }, + { "file": "backend/tests/sync-v2-mobile-multidevice.test.ts", "case": "native structural Apply rejects foreign scopes" }, + { "file": "backend/tests/sync-v2-mobile-multidevice.test.ts", "case": "native structural Apply validates final ancestry" }, + { "file": "backend/tests/sync-v2-mobile-multidevice.test.ts", "case": "native tree Snapshot reconciles stale metadata" }, + { "file": "backend/tests/sync-v2-mobile-multidevice.test.ts", "case": "native file tree metadata requires its scoped attachment" }, + { "file": "backend/tests/sync-v2-mobile-multidevice.test.ts", "case": "native tree subscription completes multiple Snapshot pages" }, + { "file": "frontend/src/lib/__tests__/mobileNativeTreeSubscription.test.ts", "case": "refuses to silently downgrade a persisted tree subscription" }, + { "file": "frontend/src/lib/__tests__/mobileNativeTreeSubscription.test.ts", "case": "negotiates the complete existing set" }, + { "file": "frontend/src/lib/encryptedNotes/__tests__/nativeDatabaseInitialization.test.ts", "case": "upgrades schema 5 with structural metadata" }, + { "file": "frontend/src/lib/encryptedNotes/__tests__/nativeDatabaseInitialization.test.ts", "case": "rolls back a failed schema 5 tree migration" } ] } ] diff --git a/frontend/src/lib/__tests__/mobileNativeTreeSubscription.test.ts b/frontend/src/lib/__tests__/mobileNativeTreeSubscription.test.ts new file mode 100644 index 000000000..33bc27be4 --- /dev/null +++ b/frontend/src/lib/__tests__/mobileNativeTreeSubscription.test.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { MobileSyncEngine, assertMobileSyncItems } from "../mobileSyncEngine"; +import { NATIVE_TREE_ENTITY_SET } from "../nativeKnowledgeTreeSync"; + +const scope = {scopeKey:"personal",workspaceId:null,workspaceName:null,role:"owner",canWrite:true,accessFingerprint:"test"}; +function fixture(bound=false) { + const db = { query:vi.fn().mockResolvedValue(bound ? [{value:NATIVE_TREE_ENTITY_SET}] : []),run:vi.fn(),transaction:vi.fn() }; + const engine = new MobileSyncEngine({db:db as never,attachments:{} as never,serverUrl:"http://localhost:3001",token:"test",userId:"user",profileId:"profile",deviceId:"device"}); + return {db,engine:engine as unknown as { + negotiateTree(value:typeof scope):Promise; + subscriptionParams(value:typeof scope):URLSearchParams; + }}; +} +const response = (body:unknown,status=200) => ({ok:status===200,status,json:async()=>body}); +afterEach(() => { vi.unstubAllGlobals(); }); + +describe("native personal tree subscription", () => { + it("negotiates the complete existing set and a stable device identity once per session", async () => { + const {engine} = fixture(); + const fetchMock = vi.fn().mockResolvedValue(response({entityTypes:NATIVE_TREE_ENTITY_SET.split(",")})); + vi.stubGlobal("fetch",fetchMock); + await engine.negotiateTree(scope); await engine.negotiateTree(scope); + expect(fetchMock).toHaveBeenCalledTimes(1); + const params = engine.subscriptionParams(scope); + expect(params.get("entityTypes")).toBe(NATIVE_TREE_ENTITY_SET); expect(params.get("deviceId")).toBe("device"); + expect(engine.subscriptionParams({...scope,workspaceId:"w",scopeKey:"workspace:w"} as never).has("entityTypes")).toBe(false); + expect(() => assertMobileSyncItems([{entityType:"knowledge_tree_node",entityId:"note:n1",operation:"upsert"}],"changes",true)).not.toThrow(); + }); + it.each([undefined,NATIVE_TREE_ENTITY_SET.split(",").slice(0,-1)])("keeps the legacy protocol on a fresh profile when an old server reports %s", async (entityTypes) => { + const {db,engine} = fixture(); + vi.stubGlobal("fetch",vi.fn().mockResolvedValue(response({entityTypes}))); + await engine.negotiateTree(scope); + expect(engine.subscriptionParams(scope).has("entityTypes")).toBe(false); + expect(db.run).not.toHaveBeenCalled(); + }); + it("accepts an explicit unsupported-set response only before a tree cursor has been bound", async () => { + vi.stubGlobal("fetch",vi.fn().mockResolvedValue(response({code:"INVALID_PAYLOAD"},400))); + const fresh = fixture(); await fresh.engine.negotiateTree(scope); + expect(fresh.engine.subscriptionParams(scope).has("entityTypes")).toBe(false); + const bound = fixture(true); + await expect(bound.engine.negotiateTree(scope)).rejects.toMatchObject({code:"UNSUPPORTED_TREE_SUBSCRIPTION"}); + expect(bound.db.run).not.toHaveBeenCalled(); + }); + it("refuses to silently downgrade a persisted tree subscription even when the server returns legacy types", async () => { + const {db,engine} = fixture(true); + vi.stubGlobal("fetch",vi.fn().mockResolvedValue(response({entityTypes:NATIVE_TREE_ENTITY_SET.split(",").slice(0,-1)}))); + await expect(engine.negotiateTree(scope)).rejects.toMatchObject({code:"UNSUPPORTED_TREE_SUBSCRIPTION"}); + expect(db.run).not.toHaveBeenCalled(); + }); + it.each(["AUTH_EXPIRED","SCOPE_FORBIDDEN"])("preserves %s failures instead of falling back", async (code) => { + const {engine} = fixture(); + vi.stubGlobal("fetch",vi.fn().mockResolvedValue(response({code},code==="AUTH_EXPIRED"?401:403))); + await expect(engine.negotiateTree(scope)).rejects.toMatchObject({code}); + }); + it.each([["knowledge_tree_node"],["future_entity"],Array(11).fill("note")])("rejects malformed or partial entity sets: %s", async (entityTypes) => { + const {engine} = fixture(); + vi.stubGlobal("fetch",vi.fn().mockResolvedValue(response({entityTypes}))); + await expect(engine.negotiateTree(scope)).rejects.toMatchObject({code:"SERVER_ERROR"}); + }); + it("retries network failures without marking a legacy subscription as negotiated", async () => { + const {engine} = fixture(); + const fetchMock = vi.fn().mockRejectedValueOnce(new TypeError("network")).mockResolvedValueOnce(response({entityTypes:NATIVE_TREE_ENTITY_SET.split(",")})); + vi.stubGlobal("fetch",fetchMock); + await expect(engine.negotiateTree(scope)).rejects.toMatchObject({code:"NETWORK_UNAVAILABLE"}); await engine.negotiateTree(scope); + expect(engine.subscriptionParams(scope).get("entityTypes")).toBe(NATIVE_TREE_ENTITY_SET); + }); +}); diff --git a/frontend/src/lib/__tests__/mobileSyncSwitch.test.ts b/frontend/src/lib/__tests__/mobileSyncSwitch.test.ts index 1d82862e7..a1eaa5089 100644 --- a/frontend/src/lib/__tests__/mobileSyncSwitch.test.ts +++ b/frontend/src/lib/__tests__/mobileSyncSwitch.test.ts @@ -104,6 +104,7 @@ beforeEach(async () => { mocks.fetch.mockImplementation(async (url: string, init?: RequestInit) => { let payload: unknown; if (url.endsWith("/scopes")) payload = { items: [{ scopeKey: "personal", workspaceId: null, workspaceName: null, role: null, canWrite: true, accessFingerprint: "test" }] }; + else if (url.includes("/plan?")) payload = { entityTypes: ["notebook","note","tag","note_tag","favorite","attachment","task","task_reminder","diary","mindmap"] }; else if (url.includes("/push?")) { const { mutations } = JSON.parse(String(init?.body)); payload = { serverSequence: 1, results: mutations.map(({ mutationId, baseVersion, payload: note }: { mutationId: string; baseVersion?: number; payload?: {version?:number} }) => ({ diff --git a/frontend/src/lib/__tests__/mobileSyncUnknownEntity.test.ts b/frontend/src/lib/__tests__/mobileSyncUnknownEntity.test.ts index 98953a8f3..ae41daa64 100644 --- a/frontend/src/lib/__tests__/mobileSyncUnknownEntity.test.ts +++ b/frontend/src/lib/__tests__/mobileSyncUnknownEntity.test.ts @@ -75,7 +75,7 @@ describe("mobile Sync V2 protocol safety", () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ resetRequired: false, nextSequence: 6, items: [ - { entityType: "knowledge_tree_node", entityId: "note:n1", operation: "upsert" }, + { entityType: "future_entity", entityId: "note:n1", operation: "upsert" }, ] }), }); vi.stubGlobal("fetch", fetchMock); @@ -91,7 +91,7 @@ describe("mobile Sync V2 protocol safety", () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ resetRequired: true, nextSequence: 6, items: [ - { entityType: "knowledge_tree_node", entityId: "note:n1", operation: "upsert" }, + { entityType: "future_entity", entityId: "note:n1", operation: "upsert" }, ] }), }); vi.stubGlobal("fetch", fetchMock); @@ -107,7 +107,7 @@ describe("mobile Sync V2 protocol safety", () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ snapshotSequence: 6, nextCursor: null, items: [ - { entityType: "knowledge_tree_node", entityId: "note:n1", payload: {} }, + { entityType: "future_entity", entityId: "note:n1", payload: {} }, ] }), }); vi.stubGlobal("fetch", fetchMock); diff --git a/frontend/src/lib/__tests__/voiceMemoLocalFirst.test.tsx b/frontend/src/lib/__tests__/voiceMemoLocalFirst.test.tsx index 11f668a5a..3b4b35602 100644 --- a/frontend/src/lib/__tests__/voiceMemoLocalFirst.test.tsx +++ b/frontend/src/lib/__tests__/voiceMemoLocalFirst.test.tsx @@ -7,7 +7,7 @@ import type { NativeAttachmentStore } from "../nativeAttachmentStore"; // 只隔离无关业务门面;笔记写入、Outbox 序列化、同步请求和音频渲染使用真实实现。 vi.mock("@/lib/api", async () => { const bridge = await import("@/lib/noteAttachmentAccessBridge"); - return { api: { attachments: {}, attachmentFolders: {} }, resolveAttachmentUrl: bridge.resolveAttachmentAccessUrl }; + return { api: { attachments: {}, attachmentFolders: {} }, getBaseUrl: () => "https://server.example", resolveAttachmentUrl: bridge.resolveAttachmentAccessUrl }; }); vi.mock("../mobileLocalMode", () => ({ isMobileLocalMode: () => false })); vi.mock("../mobileLocalKnowledgeTreeBridge", () => ({ installMobileLocalKnowledgeTreeBridge: () => () => undefined })); diff --git a/frontend/src/lib/encryptedNotes/__tests__/nativeDatabaseInitialization.test.ts b/frontend/src/lib/encryptedNotes/__tests__/nativeDatabaseInitialization.test.ts index 98adcbcc0..ce604ae50 100644 --- a/frontend/src/lib/encryptedNotes/__tests__/nativeDatabaseInitialization.test.ts +++ b/frontend/src/lib/encryptedNotes/__tests__/nativeDatabaseInitialization.test.ts @@ -20,13 +20,13 @@ vi.mock("@capacitor-community/sqlite", () => ({ beforeEach(() => { mocks.version = 0; mocks.execute.mockReset().mockResolvedValue({}); mocks.run.mockReset().mockResolvedValue({}); mocks.close.mockReset().mockResolvedValue(undefined); Reflect.deleteProperty(globalThis, "__nowenNoteNativeDatabaseState"); }); afterEach(() => { Reflect.deleteProperty(globalThis, "__nowenNoteNativeDatabaseState"); }); -it.each([0, 4])("initializing schema %s installs all persisted guards before acknowledging version 5", async (version) => { +it.each([0, 4])("initializing schema %s installs all persisted guards before acknowledging version 6", async (version) => { mocks.version = version; const db = await openNativeDatabase("encryption-test-account"); const statements = mocks.execute.mock.calls.map(([sql]) => sql); for (const guard of NATIVE_ENCRYPTED_NOTE_GUARDS) { expect(statements).toContain(guard); - expect(statements.indexOf(guard)).toBeLessThan(statements.indexOf("PRAGMA user_version = 5")); + expect(statements.indexOf(guard)).toBeLessThan(statements.indexOf("PRAGMA user_version = 6")); } expect(statements.at(-1)).toBe("COMMIT"); await db.close(); @@ -37,7 +37,30 @@ it("a failed guard install rolls back without acknowledging a migrated schema", await expect(openNativeDatabase("encryption-test-account")).rejects.toThrow("Failed DDL"); const statements = mocks.execute.mock.calls.map(([sql]) => sql); expect(statements.at(-1)).toBe("ROLLBACK"); - expect(statements).not.toContain("PRAGMA user_version = 5"); expect(statements).not.toContain("COMMIT"); + expect(statements).not.toContain("PRAGMA user_version = 6"); expect(statements).not.toContain("COMMIT"); +}); + +it("upgrades schema 5 with structural metadata before acknowledging version 6", async () => { + mocks.version = 5; + const db = await openNativeDatabase("tree-upgrade-account"); + const statements = mocks.execute.mock.calls.map(([sql]) => sql as string); + const table = statements.findIndex((sql) => sql.startsWith("CREATE TABLE IF NOT EXISTS native_knowledge_tree_nodes")); + expect(table).toBeGreaterThan(0); + expect(table).toBeLessThan(statements.indexOf("PRAGMA user_version = 6")); + expect(statements.some((sql) => /DROP TABLE|DELETE FROM/.test(sql))).toBe(false); + await db.close(); +}); + +it("rolls back a failed schema 5 tree migration without advancing the version", async () => { + mocks.version = 5; + mocks.execute.mockImplementation(async (sql: string) => { + if (sql.startsWith("CREATE TABLE IF NOT EXISTS native_knowledge_tree_nodes")) throw new Error("Tree DDL failure"); + return {}; + }); + await expect(openNativeDatabase("tree-failed-upgrade-account")).rejects.toThrow("Tree DDL failure"); + const statements = mocks.execute.mock.calls.map(([sql]) => sql); + expect(statements.at(-1)).toBe("ROLLBACK"); + expect(statements).not.toContain("PRAGMA user_version = 6"); }); async function verifyConcurrentRead(rollback: boolean) { diff --git a/frontend/src/lib/mobileSyncEngine.ts b/frontend/src/lib/mobileSyncEngine.ts index af7b847ac..a2f205c0f 100644 --- a/frontend/src/lib/mobileSyncEngine.ts +++ b/frontend/src/lib/mobileSyncEngine.ts @@ -8,11 +8,12 @@ import { forgetUnsentLocalNotes } from "./nativeLocalNoteOrigin"; import { notifyMobileSyncStatusChanged, type MobileSyncAppliedDetail } from "./mobileSyncStatus"; import { nativeReceiptKey } from "./mobileNoteSyncReceipt"; import { validateEncryptedNoteWrite } from "./encryptedNotes/noteDocument"; +import { applyNativePersonalTree, NATIVE_TREE_ENTITY_SET, nativeTreeSubscriptionKey, type NativeTreeEntry } from "./nativeKnowledgeTreeSync"; type ScopeStatus = "active" | "replan_required" | "access_revoked"; type EntityType = "notebook" | "note" | "tag" | "note_tag" | "favorite" | "attachment" | "task" | "task_reminder" | "diary" | "mindmap"; -type RemoteEntityType = EntityType; +type RemoteEntityType = EntityType | "knowledge_tree_node"; interface ScopeDescriptor { scopeKey: string; @@ -100,12 +101,12 @@ function syncFailure(code:string,message:string):Error&{code:string}{ } /** Reject future protocol entities before a mobile cursor or ACK can advance. */ -export function assertMobileSyncItems(items:unknown,kind:"snapshot"|"changes"):void{ +export function assertMobileSyncItems(items:unknown,kind:"snapshot"|"changes",treeSubscription=false):void{ if(!Array.isArray(items))throw syncFailure("SERVER_ERROR","远端同步条目格式无效"); for(const item of items){ if(!item||typeof item!=="object")throw syncFailure("SERVER_ERROR","远端同步条目格式无效"); const entry=item as Record; - if(!isCoreEntityType(entry.entityType))throw syncFailure("SERVER_ERROR",`不支持的同步实体:${String(entry.entityType)}`); + if(!isCoreEntityType(entry.entityType) && !(treeSubscription && entry.entityType === "knowledge_tree_node"))throw syncFailure("SERVER_ERROR",`不支持的同步实体:${String(entry.entityType)}`); if(typeof entry.entityId!=="string"||!entry.entityId)throw syncFailure("SERVER_ERROR","远端同步实体 ID 无效"); if(kind==="changes"&&entry.operation!=="upsert"&&entry.operation!=="delete") throw syncFailure("SERVER_ERROR","远端同步操作无效"); @@ -120,6 +121,8 @@ export class MobileSyncEngine { private debounce: ReturnType | null = null; private stopped = true; private syncAbort: AbortController | null = null; + private treeNegotiated = false; + private treeSubscribed = false; constructor(private readonly options: MobileSyncOptions) {} @@ -181,12 +184,16 @@ export class MobileSyncEngine { ))[0]; if (state?.accessStatus === "access_revoked") continue; try { + await this.negotiateTree(scope); // Replay durable mutation IDs before reconciling a snapshot. After a // lost ACK, the server already has our create; bootstrap alone cannot // distinguish that state from a competing creation on another device. await this.push(scope); if (this.stopped) return; - if (!state || state.lastSequence === 0 || state.accessStatus === "replan_required") { + const treeBinding = !this.hasTreeSubscription(scope) ? true : (await this.options.db.query<{value:string}>( + "SELECT value FROM native_runtime_meta WHERE key=?", [nativeTreeSubscriptionKey(this.options.profileId)], + ))[0]?.value === NATIVE_TREE_ENTITY_SET; + if (!state || state.lastSequence === 0 || state.accessStatus === "replan_required" || !treeBinding) { await this.bootstrap(scope); } if (this.stopped) return; @@ -244,6 +251,7 @@ export class MobileSyncEngine { }>("SELECT * FROM sync_conflicts WHERE id=? AND profileId=?",[conflictId,this.options.profileId]))[0]; if(!row||row.status==="resolved")return; if(!isCoreEntityType(row.entityType))throw new Error("该冲突类型暂不支持在移动端合并"); + const entityType = row.entityType; const parse=(value:string|null)=>value?JSON.parse(value) as Record:null; const selected=resolution==="manual"?mergedPayload:parse(resolution==="keep-remote"?row.remotePayload:row.localPayload); if(!selected)throw new Error("缺少可用的冲突内容"); @@ -259,7 +267,7 @@ export class MobileSyncEngine { // The editor can keep saving while the conflict is awaiting a choice. // Keep-local must preserve that latest durable edit, not its old snapshot. const chosen = resolution === "keep-local" - ? await this.readEntity(tx, row.scopeKey, row.entityType, row.entityId) || selected : selected; + ? await this.readEntity(tx, row.scopeKey, entityType, row.entityId) || selected : selected; const payload = row.entityType === "note" && resolution !== "keep-remote" && typeof row.remoteVersion === "number" && Number.isSafeInteger(row.remoteVersion) && row.remoteVersion > 0 ? { ...chosen, version: row.remoteVersion + 1 } : chosen; @@ -446,25 +454,68 @@ export class MobileSyncEngine { private async bootstrap(scope: ScopeDescriptor): Promise { let cursor: string | null = null; let sequence = 0; - const seen=new Map>(); + const seen=new Map>(); const notebookParents=new Map(); + const tree: NativeTreeEntry[] = []; do { - const params = new URLSearchParams({ scopeKey:scope.scopeKey, limit:"200" }); + const params = this.subscriptionParams(scope, { limit:"200" }); if (cursor) params.set("cursor",cursor); if (sequence) params.set("snapshotSequence",String(sequence)); const page = await this.request<{ snapshotSequence:number;nextCursor:string|null;items:SnapshotEntry[] }>(`/snapshot?${params}`); - assertMobileSyncItems(page.items,"snapshot"); + assertMobileSyncItems(page.items,"snapshot",this.hasTreeSubscription(scope)); if (!sequence) sequence = page.snapshotSequence; for(const entry of page.items){ const ids=seen.get(entry.entityType)||new Set();ids.add(entry.entityId);seen.set(entry.entityType,ids); if(entry.entityType==="notebook"&&typeof entry.payload.parentId==="string")notebookParents.set(entry.entityId,entry.payload.parentId); } - await this.applyEntries(scope,page.items,true); + tree.push(...page.items.filter((entry) => entry.entityType === "knowledge_tree_node")); + await this.applyEntries(scope,page.items.filter((entry) => entry.entityType !== "knowledge_tree_node"),true); cursor = page.nextCursor; } while (cursor); await this.restoreNotebookParents(scope,notebookParents); + if (this.hasTreeSubscription(scope)) await this.options.db.transaction((tx) => applyNativePersonalTree(tx,tree,this.options.userId,true)); if(scope.workspaceId)await this.pruneWorkspaceSnapshot(scope,seen); - await this.advance(scope,sequence); + await this.advance(scope,sequence,this.hasTreeSubscription(scope)); + } + + private hasTreeSubscription(scope: ScopeDescriptor): boolean { + return !scope.workspaceId && this.treeSubscribed; + } + + private async negotiateTree(scope: ScopeDescriptor): Promise { + if (scope.workspaceId || this.treeNegotiated) return; + const bound = (await this.options.db.query<{value:string}>( + "SELECT value FROM native_runtime_meta WHERE key=?", [nativeTreeSubscriptionKey(this.options.profileId)], + ))[0]?.value === NATIVE_TREE_ENTITY_SET; + const params = new URLSearchParams({scopeKey:scope.scopeKey,entityTypes:NATIVE_TREE_ENTITY_SET,deviceId:this.options.deviceId}); + let supported = false; + try { + const plan = await this.request<{entityTypes?:unknown}>(`/plan?${params}`); + const types = plan.entityTypes; + if (types !== undefined && (!Array.isArray(types) || types.some((type) => !NATIVE_TREE_ENTITY_SET.split(",").includes(type)))) { + throw syncFailure("SERVER_ERROR","远端实体订阅响应无效"); + } + supported = Array.isArray(types) && types.length === 11 && new Set(types).size === 11; + if (Array.isArray(types) && !supported && (types.length !== 10 || new Set(types).size !== 10 || types.includes("knowledge_tree_node"))) { + throw syncFailure("SERVER_ERROR","远端实体订阅响应不完整"); + } + } catch (error) { + // Old servers may reject the new set. Fall back only before this profile + // has ever bound a tree cursor; never silently downgrade an existing one. + if ((error as {code?:string}).code !== "INVALID_PAYLOAD") throw error; + } + if (!supported && bound) throw syncFailure("UNSUPPORTED_TREE_SUBSCRIPTION","当前服务器不支持已建立的知识树订阅,已保留本地数据与游标"); + this.treeSubscribed = supported; + this.treeNegotiated = true; + } + + private subscriptionParams(scope: ScopeDescriptor, extra: Record = {}): URLSearchParams { + const params = new URLSearchParams({scopeKey:scope.scopeKey,...extra}); + if (this.hasTreeSubscription(scope)) { + params.set("entityTypes",NATIVE_TREE_ENTITY_SET); + params.set("deviceId",this.options.deviceId); + } + return params; } private async restoreNotebookParents(scope:ScopeDescriptor,links:Map):Promise{ @@ -477,7 +528,7 @@ export class MobileSyncEngine { }); } - private async pruneWorkspaceSnapshot(scope:ScopeDescriptor,seen:Map>):Promise{ + private async pruneWorkspaceSnapshot(scope:ScopeDescriptor,seen:Map>):Promise{ const attachmentIds=[...(seen.get("attachment")||[])]; const stale=await this.options.db.query<{id:string}>(`SELECT id FROM attachments WHERE scopeKey=? ${attachmentIds.length?`AND id NOT IN (${attachmentIds.map(()=>"?").join(",")})`:""}`,[scope.scopeKey,...attachmentIds]); @@ -568,7 +619,7 @@ export class MobileSyncEngine { const noteIds = rows.filter((row) => row.entityType === "note").map((row) => row.entityId); if (noteIds.length) await this.options.db.transaction((tx) => forgetUnsentLocalNotes(tx, scope.scopeKey, noteIds)); const response = await this.request<{ serverSequence:number;results:Array<{mutationId:string;status:string;code?:string;version?:number;serverVersion?:number;serverPayload?:Record}> }>( - `/push?scopeKey=${encodeURIComponent(scope.scopeKey)}`, + `/push?${this.subscriptionParams(scope)}`, { method:"POST",body:JSON.stringify({scopeKey:scope.scopeKey,deviceId:this.options.deviceId,mutations}) }, ); for (const result of response.results) { @@ -634,9 +685,9 @@ export class MobileSyncEngine { ))[0]; const after = state?.lastSequence || 0; const changes = await this.request<{ resetRequired:boolean;nextSequence:number;items:Array<{entityType:RemoteEntityType;entityId:string;operation:string}> }>( - `/changes?scopeKey=${encodeURIComponent(scope.scopeKey)}&after=${after}`, + `/changes?${this.subscriptionParams(scope,{after:String(after)})}`, ); - assertMobileSyncItems(changes.items,"changes"); + assertMobileSyncItems(changes.items,"changes",this.hasTreeSubscription(scope)); if (changes.resetRequired) { await this.bootstrap(scope); return; } // Snapshot contains current entities, so intermediate upserts of an entity // already deleted in this feed page must not be requested. @@ -648,10 +699,10 @@ export class MobileSyncEngine { if (wanted.size) { let cursor: string|null=null; do { - const params = new URLSearchParams({scopeKey:scope.scopeKey,limit:"200"}); + const params = this.subscriptionParams(scope,{limit:"200"}); if(cursor) params.set("cursor",cursor); const page = await this.request<{nextCursor:string|null;items:SnapshotEntry[]}>(`/snapshot?${params}`); - assertMobileSyncItems(page.items,"snapshot"); + assertMobileSyncItems(page.items,"snapshot",this.hasTreeSubscription(scope)); for(const item of page.items){const key=`${item.entityType}\0${item.entityId}`;if(wanted.delete(key))entries.push(item);} cursor=page.nextCursor; } while(cursor&&wanted.size); @@ -664,17 +715,22 @@ export class MobileSyncEngine { await this.advance(scope,changes.nextSequence); } - private async advance(scope: ScopeDescriptor, sequence: number): Promise { - await this.request(`/ack?scopeKey=${encodeURIComponent(scope.scopeKey)}`,{ + private async advance(scope: ScopeDescriptor, sequence: number, replaceCursor=false): Promise { + await this.request(`/ack?${this.subscriptionParams(scope)}`,{ method:"POST",body:JSON.stringify({scopeKey:scope.scopeKey,deviceId:this.options.deviceId,sequence}), }); - await this.options.db.run(`UPDATE sync_state SET lastSequence=MAX(lastSequence,?),lastSyncAt=?, - lastError=NULL,accessStatus='active' WHERE profileId=? AND scopeKey=?`, - [sequence,now(),this.options.profileId,scope.scopeKey]); + await this.options.db.transaction(async (tx) => { + await tx.run(`UPDATE sync_state SET lastSequence=${replaceCursor ? "?" : "MAX(lastSequence,?)"},lastSyncAt=?, + lastError=NULL,accessStatus='active' WHERE profileId=? AND scopeKey=?`, + [sequence,now(),this.options.profileId,scope.scopeKey]); + if (replaceCursor) await tx.run(`INSERT INTO native_runtime_meta (key,value,updatedAt) VALUES (?,?,?) + ON CONFLICT(key) DO UPDATE SET value=excluded.value,updatedAt=excluded.updatedAt`, + [nativeTreeSubscriptionKey(this.options.profileId),NATIVE_TREE_ENTITY_SET,now()]); + }); } private async applyEntries(scope: ScopeDescriptor, entries: SnapshotEntry[], bootstrap: boolean): Promise { - assertMobileSyncItems(entries,"snapshot"); + assertMobileSyncItems(entries,"snapshot",this.hasTreeSubscription(scope)); for (const entry of entries) if (entry.entityType === "note" && !entry.payload.__delete) validateEncryptedNoteWrite(entry.payload); const notebookParents=entries.flatMap((entry)=>entry.entityType==="notebook" && typeof entry.payload.parentId==="string" ? [[entry.entityId,entry.payload.parentId] as const] : []); @@ -683,6 +739,7 @@ export class MobileSyncEngine { // 即使远端内容进入冲突中心而未覆盖本地,它的存在也已得到确认。 await forgetUnsentLocalNotes(tx, scope.scopeKey, entries.filter((entry) => entry.entityType === "note").map((entry) => entry.entityId)); for(const entry of entries){ + if (entry.entityType === "knowledge_tree_node") continue; if (entry.entityType === "note" && !entry.payload.__delete) { const current = await this.readEntity(tx, scope.scopeKey, "note", entry.entityId); if (current) validateEncryptedNoteWrite(entry.payload, current); @@ -725,6 +782,11 @@ export class MobileSyncEngine { await this.applyEntity(tx,scope,entry); applied.push(entry); } + const tree = entries.filter((entry) => entry.entityType === "knowledge_tree_node"); + if (tree.length) { + await applyNativePersonalTree(tx,tree,this.options.userId,false); + applied.push(...tree); + } for(const [id,parentId] of notebookParents){ const parent=(await tx.query<{id:string}>("SELECT id FROM notebooks WHERE scopeKey=? AND id=?",[scope.scopeKey,parentId]))[0]; if(parent)await tx.run("UPDATE notebooks SET parentId=? WHERE scopeKey=? AND id=?",[parentId,scope.scopeKey,id]); @@ -746,6 +808,7 @@ export class MobileSyncEngine { } private async applyEntity(db:NativeDatabase,scope:ScopeDescriptor,entry:SnapshotEntry):Promise{ + if (entry.entityType === "knowledge_tree_node") throw syncFailure("SERVER_ERROR","知识树必须通过完整结构 Apply 事务接收"); const p=entry.payload;const deleting=Boolean(p.__delete);const key=scope.scopeKey;const ws=scope.workspaceId; if (entry.entityType === "note" && !deleting) { const current = await this.readEntity(db, key, "note", entry.entityId); diff --git a/frontend/src/lib/nativeDatabase.ts b/frontend/src/lib/nativeDatabase.ts index f99c35c03..4aaed5012 100644 --- a/frontend/src/lib/nativeDatabase.ts +++ b/frontend/src/lib/nativeDatabase.ts @@ -33,7 +33,7 @@ type NativeDatabaseGlobal = typeof globalThis & { __nowenNoteNativeDatabaseState?: NativeDatabaseGlobalState; }; -const SCHEMA_VERSION = 5; +const SCHEMA_VERSION = 6; const DATABASE_PREFIX = "nowen_local_"; const ENTITY_TYPE_CHECK = ` @@ -134,6 +134,30 @@ const MOBILE_MODULE_SCHEMA_STATEMENTS = [ ON mindmap_folders(scopeKey, parentId, sortOrder)`, ] as const; +// Structural metadata only. This table is never evidence of ACL/password access +// and is deliberately outside the locally writable Outbox entity registry. +const KNOWLEDGE_TREE_SCHEMA_STATEMENTS = [ + `CREATE TABLE IF NOT EXISTS native_knowledge_tree_nodes ( + scopeKey TEXT NOT NULL, + workspaceId TEXT, + id TEXT NOT NULL, + userId TEXT NOT NULL, + parentId TEXT, + nodeType TEXT NOT NULL, + resourceType TEXT NOT NULL CHECK (resourceType IN ('notebook','note','mindmap','file')), + resourceId TEXT NOT NULL, + sortOrder INTEGER NOT NULL CHECK (sortOrder >= 0), + isDeleted INTEGER NOT NULL CHECK (isDeleted IN (0,1)), + deletedAt TEXT, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL, + PRIMARY KEY (scopeKey, id), + CHECK (${SCOPE_CHECK}) + )`, + `CREATE INDEX IF NOT EXISTS idx_native_tree_parent + ON native_knowledge_tree_nodes(scopeKey,parentId,sortOrder)`, +] as const; + const SCHEMA_V1_STATEMENTS = [ `CREATE TABLE IF NOT EXISTS notebooks ( id TEXT NOT NULL, @@ -698,6 +722,7 @@ class NativeDatabaseImpl implements NativeDatabase { for (const statement of SCHEMA_V1_STATEMENTS) { await this.raw.execute(statement, false); } + await executeSchemaStatements(this.raw, KNOWLEDGE_TREE_SCHEMA_STATEMENTS); for (const statement of NATIVE_ENCRYPTED_NOTE_GUARDS) await this.raw.execute(statement, false); await this.raw.run( `INSERT INTO native_runtime_meta (key, value, updatedAt) @@ -729,13 +754,14 @@ class NativeDatabaseImpl implements NativeDatabase { if (version === 1) { await upgradeSchemaV1ToV2(this.raw); await upgradeSchemaV2ToV3(this.raw); - await this.raw.execute(`PRAGMA user_version = ${SCHEMA_VERSION}`, false); } else if (version === 2) { await upgradeSchemaV2ToV3(this.raw); - await this.raw.execute(`PRAGMA user_version = ${SCHEMA_VERSION}`, false); } if (version < 5) { for (const statement of NATIVE_ENCRYPTED_NOTE_GUARDS) await this.raw.execute(statement, false); + } + if (version < 6) { + await executeSchemaStatements(this.raw, KNOWLEDGE_TREE_SCHEMA_STATEMENTS); await this.raw.execute(`PRAGMA user_version = ${SCHEMA_VERSION}`, false); } }); diff --git a/frontend/src/lib/nativeKnowledgeTreeSync.ts b/frontend/src/lib/nativeKnowledgeTreeSync.ts new file mode 100644 index 000000000..c63bf5280 --- /dev/null +++ b/frontend/src/lib/nativeKnowledgeTreeSync.ts @@ -0,0 +1,85 @@ +import type { NativeDatabase } from "./nativeDatabase"; + +// Matches the existing server's complete negotiated set. Partial subscriptions +// are rejected by Sync V2; workspace clients retain their legacy subscription. +export const NATIVE_TREE_ENTITY_SET = "notebook,note,tag,note_tag,favorite,attachment,task,task_reminder,diary,mindmap,knowledge_tree_node"; + +export function nativeTreeSubscriptionKey(profileId: string): string { + return `nativeTreeSubscription:${profileId}:personal`; +} + +export interface NativeTreeEntry { + entityId: string; + payload: Record; +} + +function invalid(message: string): never { + throw Object.assign(new Error(message), { code: "INVALID_TREE_STRUCTURE" }); +} + +const nodeTypes: Record = { + notebook: ["folder"], note: ["note", "markdown", "word"], mindmap: ["mindmap"], file: ["file"], +}; + +/** Caller owns the Apply transaction. Never writes business rows, Outbox or access proofs. */ +export async function applyNativePersonalTree( + tx: NativeDatabase, entries: NativeTreeEntry[], userId: string, snapshot: boolean, +): Promise { + const seen = new Set(); + if (snapshot) await tx.run("DELETE FROM native_knowledge_tree_nodes WHERE scopeKey='personal'"); + for (const { entityId, payload: p } of entries) { + if (seen.has(entityId)) invalid("知识树批次包含重复节点"); + seen.add(entityId); + if (p.__delete) { + if (snapshot || p.__delete !== true) invalid("知识树 Snapshot 不接受删除标记"); + await tx.run("DELETE FROM native_knowledge_tree_nodes WHERE scopeKey='personal' AND id=?", [entityId]); + continue; + } + const type = p.resourceType; + if (typeof type !== "string" || !Object.hasOwn(nodeTypes,type) + || typeof p.resourceId !== "string" || !p.resourceId + || entityId !== `${type}:${p.resourceId}` || p.id !== entityId + || p.userId !== userId || p.workspaceId !== null + || (p.scopeKey !== undefined && p.scopeKey !== `personal:${userId}`) + || typeof p.nodeType !== "string" || !nodeTypes[type].includes(p.nodeType) + || (p.parentId !== null && (typeof p.parentId !== "string" || !p.parentId)) + || !Number.isSafeInteger(p.sortOrder) || (p.sortOrder as number) < 0 + || (p.isDeleted !== 0 && p.isDeleted !== 1) + || (p.deletedAt !== null && typeof p.deletedAt !== "string") + || typeof p.createdAt !== "string" || !p.createdAt + || typeof p.updatedAt !== "string" || !p.updatedAt + || entityId.startsWith("notebook:__nowen_root_documents__:")) { + invalid("知识树资源身份、作用域或结构字段无效"); + } + const tables: Record = { notebook: "notebooks", note: "notes", mindmap: "mindmaps", file: "attachments" }; + const table = tables[type]; + const resource = await tx.query(`SELECT id FROM ${table} WHERE scopeKey='personal' AND id=?`, [p.resourceId]); + if (!resource.length) invalid("知识树业务资源尚未同步"); + await tx.run(`INSERT INTO native_knowledge_tree_nodes + (scopeKey,workspaceId,id,userId,parentId,nodeType,resourceType,resourceId,sortOrder,isDeleted,deletedAt,createdAt,updatedAt) + VALUES ('personal',NULL,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(scopeKey,id) DO UPDATE SET + parentId=excluded.parentId,nodeType=excluded.nodeType,sortOrder=excluded.sortOrder, + isDeleted=excluded.isDeleted,deletedAt=excluded.deletedAt,updatedAt=excluded.updatedAt`, + [entityId,userId,p.parentId,p.nodeType,type,p.resourceId,p.sortOrder,p.isDeleted,p.deletedAt,p.createdAt,p.updatedAt]); + } + // Validate the final graph rather than each intermediate move. A valid batch + // may reverse old ancestry; temporary intermediate cycles must not reject it. + const rows = await tx.query<{id:string;parentId:string|null;isDeleted:number}>( + "SELECT id,parentId,isDeleted FROM native_knowledge_tree_nodes WHERE scopeKey='personal'", + ); + const byId = new Map(rows.map((row) => [row.id, row])); + const done = new Set(); + for (const row of rows) { + const path = new Set(); + let current: typeof row | undefined = row; + while (current && !done.has(current.id)) { + if (path.has(current.id)) invalid("知识树不能形成循环"); + path.add(current.id); + if (!current.parentId) break; + const parent = byId.get(current.parentId); + if (!parent || (parent.isDeleted && !current.isDeleted)) invalid("知识树父节点缺失或已删除"); + current = parent; + } + for (const id of path) done.add(id); + } +} diff --git a/frontend/tests/android-sync/main.ts b/frontend/tests/android-sync/main.ts index 94bc5b087..78b7da524 100644 --- a/frontend/tests/android-sync/main.ts +++ b/frontend/tests/android-sync/main.ts @@ -62,6 +62,10 @@ async function initializeOnce(config: Config) { stop() { dispose?.(); dispose = undefined; engine.stop(); setMobileSyncEnabled(false); realtime.disconnect(); }, async sync() { await engine.syncOnce(); }, async errors() { return db.query("SELECT lastError FROM sync_state WHERE profileId=?", [profileId]); }, + async structure(id: string) { + return (await db.query("SELECT id,parentId,sortOrder,isDeleted FROM native_knowledge_tree_nodes WHERE scopeKey='personal' AND id=?", [id]))[0] || null; + }, + async schema() { return db.query("PRAGMA user_version"); }, }; Object.assign(window, { acceptance, acceptanceRealtime: realtime }); document.getElementById("status")!.textContent = "Native SQLite ready. Test account only."; diff --git a/scripts/android-sync-acceptance.mjs b/scripts/android-sync-acceptance.mjs index a382f8fdd..e4aa85cba 100644 --- a/scripts/android-sync-acceptance.mjs +++ b/scripts/android-sync-acceptance.mjs @@ -89,18 +89,34 @@ try { await remoteEdit(ids[0], "reconnected"); await native("start"); await wait(async () => (await native("status"))[0].note.contentText === "reconnected", "reconnect did not catch up"); + const folderResponse = await fetch(`${base}/acceptance/tree-folder`, {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({parentId:`note:${ids[0]}`})}); + assert.ok(folderResponse.ok, `remote folder create failed: ${folderResponse.status}`); + const folder = await folderResponse.json(); + await wait(async () => (await native("structure",folder.id))?.parentId === `note:${ids[0]}`, "folder-under-document structure not pulled"); + const moved = await fetch(`${base}/acceptance/tree-move/${encodeURIComponent(folder.id)}`, {method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({parentId:null,sortOrder:17})}); + assert.ok(moved.ok, `remote tree move failed: ${moved.status}`); + await wait(async () => {const row=await native("structure",folder.id);return row?.parentId === null && row.sortOrder === 17;}, "remote tree move not pulled"); + adb("reverse", "--remove", "tcp:47831"); + await browser.close(); + adb("shell", "am", "force-stop", "com.nowen.note.syncacceptance"); + adb("shell", "am", "start", "-n", "com.nowen.note.syncacceptance/com.nowen.note.MainActivity"); + await attach(); + const restoredStructure = await native("structure",folder.id); + assert.equal(restoredStructure.parentId,null); assert.equal(restoredStructure.sortOrder,17); + assert.equal((await native("schema"))[0].user_version,6); const report = { device: serial, package: "com.nowen.note.syncacceptance", offlineImmediateRead: true, forceStopRecovery: true, knowledgeTreeRootCreate: true, readWriteThroughProductionApiBridge: true, + negotiatedPersonalTreePull:true,remoteFolderUnderDocument:true,remoteTreeMove:true,structureForceStopRecovery:true,nativeSchemaVersion:6, offlineNetworkIsolation: "adb reverse removed; HTTP server unreachable from Android", markdownAndRichText: true, missingNoticePoll: true, reconnect: true, httpPushToNativePull: stats(download), nativeCommitToServerAndAck: stats(upload), errors: await native("errors"), - boundary: "Loopback adb reverse; real Android Native SQLite, signed-in personal knowledgeTreeApi create, api get/update and MobileSyncEngine. Does not test full EditorPane, protected/shared/cross-type tree mutations, attachment bytes, or production JWT middleware." }; + boundary: "Loopback adb reverse; real Android Native SQLite, personal tree create/read/update and negotiated structural Pull. Remote folder creation and movement use the production knowledge tree routes. Native structural writes/conflict UI, full EditorPane, protected/shared ACL, attachment bytes and full production JWT middleware are not accepted here." }; fs.mkdirSync(new URL("../docs/test-results/", import.meta.url), { recursive: true }); - fs.writeFileSync(new URL("../docs/test-results/sync-v2-native-tree-2026-10-11.json", import.meta.url), JSON.stringify(report, null, 2)); + fs.writeFileSync(new URL("../docs/test-results/sync-v2-native-tree-pull-2026-10-11.json", import.meta.url), JSON.stringify(report, null, 2)); console.log(JSON.stringify(report, null, 2)); } finally { if (page) await native("stop").catch(() => undefined); await browser?.close().catch(() => undefined); - adb("reverse", "--remove", "tcp:47831"); + try { adb("reverse", "--remove", "tcp:47831"); } catch { /* Already removed for offline structural recovery. */ } } process.exit(0); From 0e5b2e713fd9e5a87c4d0827f55fac0d83fe0243 Mon Sep 17 00:00:00 2001 From: cropflre <767308865@qq.com> Date: Sun, 11 Oct 2026 16:31:54 +0800 Subject: [PATCH 04/13] fix(sync): notify committed tree moves and retain pending native subscriptions --- .../data-consistency-contract-ci.yml | 2 + backend/src/routes/knowledge-tree.ts | 20 ++++++++ .../tests/sync-v2-mobile-multidevice.test.ts | 46 +++++++++++++++++++ .../data-consistency-contract.matrix.json | 5 +- .../mobileNativeTreeSubscription.test.ts | 9 +++- frontend/src/lib/mobileSyncEngine.ts | 16 +++++-- frontend/src/lib/nativeKnowledgeTreeSync.ts | 1 + scripts/android-sync-acceptance.mjs | 11 ++++- 8 files changed, 103 insertions(+), 7 deletions(-) diff --git a/.github/workflows/data-consistency-contract-ci.yml b/.github/workflows/data-consistency-contract-ci.yml index 0aa1f217c..e47807c4e 100644 --- a/.github/workflows/data-consistency-contract-ci.yml +++ b/.github/workflows/data-consistency-contract-ci.yml @@ -13,6 +13,7 @@ on: - "backend/src/routes/notes*" - "backend/src/routes/offline-sync*" - "backend/src/routes/sync*" + - "backend/src/routes/knowledge-tree.ts" - "backend/src/services/backup*" - "backend/src/runtime/backup*" - "backend/tests/sync-v2-*.test.ts" @@ -54,6 +55,7 @@ on: - "backend/src/routes/notes*" - "backend/src/routes/offline-sync*" - "backend/src/routes/sync*" + - "backend/src/routes/knowledge-tree.ts" - "backend/src/services/backup*" - "backend/src/runtime/backup*" - "backend/tests/sync-v2-*.test.ts" diff --git a/backend/src/routes/knowledge-tree.ts b/backend/src/routes/knowledge-tree.ts index 7d36ecb56..cdffb8a7e 100644 --- a/backend/src/routes/knowledge-tree.ts +++ b/backend/src/routes/knowledge-tree.ts @@ -6,6 +6,7 @@ import { getDb } from "../db/schema.js"; import { MINDMAP_LEGACY_NOTEBOOK_PREFIX } from "../db/knowledgeTreeMindmapFolderMigration.js"; import { getUserWorkspaceRole, hasRole, isFeatureEnabled, isSystemAdmin, resolveWorkspaceFeatures } from "../middleware/acl.js"; import { broadcastNotesDeleted } from "../services/realtime.js"; +import { notifySyncChanged } from "../sync/notify"; import { ensureKnowledgeTreePasswordTable } from "../db/knowledgeTreePasswordMigration.js"; import { resolvePasswordAuthorizedNotes, resolveUnlockedFolderNodeIds, signFolderUnlockToken } from "../lib/knowledgeTreePasswordAccess.js"; import { @@ -33,6 +34,25 @@ import { const app = new Hono(); +// REST tree writes also enter the durable V2 feed. Wake subscribed devices only +// after a successful request has committed; Pull retains authorization/filtering. +app.use("*", async (c, next) => { + if (!["POST","PUT","PATCH","DELETE"].includes(c.req.method)) return next(); + const db = getDb(); + const before = Number((db.prepare("SELECT MAX(sequence) AS sequence FROM sync_changes_v2").get() as {sequence:number|null}).sequence || 0); + await next(); + if (c.res.status < 200 || c.res.status >= 300) return; + const changed = db.prepare(`SELECT DISTINCT userId,workspaceId FROM sync_changes_v2 + WHERE sequence > ? AND entityType='knowledge_tree_node'`).all(before) as Array<{userId:string;workspaceId:string|null}>; + const sent = new Set(); + for (const row of changed) { + const key = row.workspaceId ? `workspace:${row.workspaceId}` : `personal:${row.userId}`; + if (sent.has(key)) continue; + sent.add(key); + notifySyncChanged(db,row.userId,row.workspaceId ? key : "personal"); + } +}); + function userIdOf(c: any): string { return c.req.header("X-User-Id") || ""; } diff --git a/backend/tests/sync-v2-mobile-multidevice.test.ts b/backend/tests/sync-v2-mobile-multidevice.test.ts index 5a7d8b07b..354eb06c8 100644 --- a/backend/tests/sync-v2-mobile-multidevice.test.ts +++ b/backend/tests/sync-v2-mobile-multidevice.test.ts @@ -4,6 +4,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { randomUUID } from "node:crypto"; +import { createRequire } from "node:module"; import Database from "better-sqlite3"; import { Hono } from "hono"; import { serve } from "@hono/node-server"; @@ -28,6 +29,7 @@ let serverUrl: string; const clients: Client[] = []; let afterPush: ((response: Response) => Promise) | undefined; let afterSnapshot: ((response: Response) => Promise) | undefined; +let afterAck: ((response: Response) => Promise) | undefined; // The real native schema, with independent on-disk SQLite databases. The adapter // serializes transactions just like NativeDatabaseImpl; no shared client state. @@ -96,6 +98,7 @@ async function conflicts(c: Client) { return c.db.query<{ localPayload: string; test.before(async () => { const schema = await import("../src/db/schema"); const routes = await import("../src/routes/sync-v2"); + const treeRoutes = await import("../src/routes/knowledge-tree"); serverDb = schema.getDb(); closeServerDb = schema.closeDb; for (const user of [USER, "other-user"]) serverDb.prepare("INSERT INTO users (id,username,passwordHash,createdAt,updatedAt) VALUES (?,?,'x','now','now')").run(user, user); const app = new Hono(); @@ -105,8 +108,10 @@ test.before(async () => { await next(); if (c.req.path.endsWith("/push") && afterPush) c.res = await afterPush(c.res); if (c.req.path.endsWith("/snapshot") && afterSnapshot) c.res = await afterSnapshot(c.res); + if (c.req.path.endsWith("/ack") && afterAck) c.res = await afterAck(c.res); }); app.route("/api/sync/v2", routes.default); + app.route("/api/knowledge-tree/", treeRoutes.default); httpServer = serve({ fetch: app.fetch, hostname: "127.0.0.1", port: 0 }); await new Promise((resolve) => httpServer.once("listening", resolve)); const address = httpServer.address(); assert.ok(address && typeof address !== "string"); @@ -178,6 +183,47 @@ test("a new native tree subscription rebuilds an existing legacy cursor without assert.ok(state.lastSequence < 999999, "the new entity set must not inherit the legacy high-water mark"); }); +test("lost initial tree ACK retains subscription intent and replays the complete baseline after reopen", async () => { + const c = await client("structure-lost-ack"), id = randomUUID(); + await c.repository.notebooks.create({ id,name:"Lost tree ACK" }); + afterAck = async () => Response.json({code:"SERVER_ERROR"},{status:503}); + try { await c.sync(); } finally { afterAck = undefined; } + const marker = (await c.db.query<{value:string}>("SELECT value FROM native_runtime_meta WHERE key=?", [`nativeTreeSubscription:${c.profileId}:personal`]))[0]; + assert.ok(marker.value.startsWith("pending:")); + assert.ok((await c.db.query("SELECT id FROM native_knowledge_tree_nodes WHERE id=?", [`notebook:${id}`])).length); + assert.equal((await c.db.query<{lastSequence:number}>("SELECT lastSequence FROM sync_state WHERE profileId=?", [c.profileId]))[0].lastSequence, 0); + await c.db.close(); + const reopened = await client("structure-lost-ack"); await reopened.sync(); + assert.equal((await reopened.db.query<{lastError:string|null}>("SELECT lastError FROM sync_state WHERE profileId=?", [c.profileId]))[0].lastError, null); + assert.ok(!(await reopened.db.query<{value:string}>("SELECT value FROM native_runtime_meta WHERE key=?", [`nativeTreeSubscription:${c.profileId}:personal`]))[0].value.startsWith("pending:")); +}); + +test("REST tree movement emits a committed metadata-only Sync V2 notice and wakes the negotiated receiver", async () => { + const c = await client("structure-rest-notice"), id = randomUUID(); + await c.repository.notebooks.create({id,name:"Private folder title must not enter notice"}); await c.sync(); + // Match routes' CommonJS loader; a late native import creates a separate tsx + // module instance and cannot register their broadcaster in this test process. + const {setSyncBroadcaster} = createRequire(path.resolve(__dirname,"../package.json"))("./src/sync/notify.ts") as typeof import("../src/sync/notify"); + const notices: Array<{recipient:string;notice:Record;committed:boolean;sortOrder:number}> = []; + setSyncBroadcaster((recipient,notice) => { + notices.push({recipient,notice:notice as Record,committed:!serverDb.inTransaction, + sortOrder:(serverDb.prepare("SELECT sortOrder FROM knowledge_tree_nodes WHERE id=?").get(`notebook:${id}`) as {sortOrder:number}).sortOrder}); + }); + try { + const before = (serverDb.prepare("SELECT MAX(sequence) AS sequence FROM sync_changes_v2").get() as {sequence:number}).sequence; + const response = await fetch(`${serverUrl}/api/knowledge-tree/nodes/${encodeURIComponent(`notebook:${id}`)}/move`, {method:"PUT",headers:{Authorization:`Bearer ${USER}`,"Content-Type":"application/json"},body:JSON.stringify({parentId:null,sortOrder:17})}); + assert.equal(response.status,200); + assert.equal(notices.length,1,JSON.stringify(serverDb.prepare("SELECT entityType,userId,workspaceId FROM sync_changes_v2 WHERE sequence>?").all(before))); assert.equal(notices[0].recipient,USER); + assert.equal(notices[0].committed,true); assert.equal(notices[0].sortOrder,17); + assert.deepEqual(Object.keys(notices[0].notice).sort(),["accessFingerprint","scopeKey","sequence","type"]); + assert.equal(notices[0].notice.type,"sync.changed"); assert.equal(notices[0].notice.scopeKey,"personal"); + const rejected = await fetch(`${serverUrl}/api/knowledge-tree/nodes/${encodeURIComponent(`notebook:${id}`)}/move`, {method:"PUT",headers:{Authorization:"Bearer other-user","Content-Type":"application/json"},body:JSON.stringify({parentId:null,sortOrder:18})}); + assert.equal(rejected.status,403); assert.equal(notices.length,1); + } finally {setSyncBroadcaster(null);} + await c.sync(); + assert.equal((await c.db.query<{sortOrder:number}>("SELECT sortOrder FROM native_knowledge_tree_nodes WHERE id=?", [`notebook:${id}`]))[0].sortOrder,17); +}); + test("invalid remote tree rolls back incremental bodies and structure without acknowledging the cursor", async () => { const c = await client("structure-invalid"), note = randomUUID(); await c.repository.createPersonalTreeNote({ id: note, contentFormat: "markdown", content: "Before invalid tree" }, null); diff --git a/docs/architecture/data-consistency-contract.matrix.json b/docs/architecture/data-consistency-contract.matrix.json index 9c7029fba..8a5ae7ff1 100644 --- a/docs/architecture/data-consistency-contract.matrix.json +++ b/docs/architecture/data-consistency-contract.matrix.json @@ -294,7 +294,10 @@ { "file": "frontend/src/lib/__tests__/mobileNativeTreeSubscription.test.ts", "case": "refuses to silently downgrade a persisted tree subscription" }, { "file": "frontend/src/lib/__tests__/mobileNativeTreeSubscription.test.ts", "case": "negotiates the complete existing set" }, { "file": "frontend/src/lib/encryptedNotes/__tests__/nativeDatabaseInitialization.test.ts", "case": "upgrades schema 5 with structural metadata" }, - { "file": "frontend/src/lib/encryptedNotes/__tests__/nativeDatabaseInitialization.test.ts", "case": "rolls back a failed schema 5 tree migration" } + { "file": "frontend/src/lib/encryptedNotes/__tests__/nativeDatabaseInitialization.test.ts", "case": "rolls back a failed schema 5 tree migration" }, + { "file": "backend/tests/sync-v2-mobile-multidevice.test.ts", "case": "lost initial tree ACK retains subscription intent" }, + { "file": "backend/tests/sync-v2-mobile-multidevice.test.ts", "case": "REST tree movement emits a committed metadata-only Sync V2 notice" }, + { "file": "frontend/src/lib/__tests__/mobileNativeTreeSubscription.test.ts", "case": "preserves subscription intent after a lost initial ACK" } ] } ] diff --git a/frontend/src/lib/__tests__/mobileNativeTreeSubscription.test.ts b/frontend/src/lib/__tests__/mobileNativeTreeSubscription.test.ts index 33bc27be4..be33a3569 100644 --- a/frontend/src/lib/__tests__/mobileNativeTreeSubscription.test.ts +++ b/frontend/src/lib/__tests__/mobileNativeTreeSubscription.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { MobileSyncEngine, assertMobileSyncItems } from "../mobileSyncEngine"; -import { NATIVE_TREE_ENTITY_SET } from "../nativeKnowledgeTreeSync"; +import { NATIVE_TREE_ENTITY_SET, NATIVE_TREE_PENDING_SUBSCRIPTION } from "../nativeKnowledgeTreeSync"; const scope = {scopeKey:"personal",workspaceId:null,workspaceName:null,role:"owner",canWrite:true,accessFingerprint:"test"}; function fixture(bound=false) { @@ -47,6 +47,13 @@ describe("native personal tree subscription", () => { await expect(engine.negotiateTree(scope)).rejects.toMatchObject({code:"UNSUPPORTED_TREE_SUBSCRIPTION"}); expect(db.run).not.toHaveBeenCalled(); }); + it("preserves subscription intent after a lost initial ACK instead of silently downgrading", async () => { + const {db,engine} = fixture(); + db.query.mockResolvedValue([{value:NATIVE_TREE_PENDING_SUBSCRIPTION}]); + vi.stubGlobal("fetch",vi.fn().mockResolvedValue(response({code:"INVALID_PAYLOAD"},400))); + await expect(engine.negotiateTree(scope)).rejects.toMatchObject({code:"UNSUPPORTED_TREE_SUBSCRIPTION"}); + expect(db.run).not.toHaveBeenCalled(); + }); it.each(["AUTH_EXPIRED","SCOPE_FORBIDDEN"])("preserves %s failures instead of falling back", async (code) => { const {engine} = fixture(); vi.stubGlobal("fetch",vi.fn().mockResolvedValue(response({code},code==="AUTH_EXPIRED"?401:403))); diff --git a/frontend/src/lib/mobileSyncEngine.ts b/frontend/src/lib/mobileSyncEngine.ts index a2f205c0f..d04e10db1 100644 --- a/frontend/src/lib/mobileSyncEngine.ts +++ b/frontend/src/lib/mobileSyncEngine.ts @@ -8,7 +8,7 @@ import { forgetUnsentLocalNotes } from "./nativeLocalNoteOrigin"; import { notifyMobileSyncStatusChanged, type MobileSyncAppliedDetail } from "./mobileSyncStatus"; import { nativeReceiptKey } from "./mobileNoteSyncReceipt"; import { validateEncryptedNoteWrite } from "./encryptedNotes/noteDocument"; -import { applyNativePersonalTree, NATIVE_TREE_ENTITY_SET, nativeTreeSubscriptionKey, type NativeTreeEntry } from "./nativeKnowledgeTreeSync"; +import { applyNativePersonalTree, NATIVE_TREE_ENTITY_SET, NATIVE_TREE_PENDING_SUBSCRIPTION, nativeTreeSubscriptionKey, type NativeTreeEntry } from "./nativeKnowledgeTreeSync"; type ScopeStatus = "active" | "replan_required" | "access_revoked"; type EntityType = "notebook" | "note" | "tag" | "note_tag" | "favorite" | "attachment" @@ -473,7 +473,14 @@ export class MobileSyncEngine { cursor = page.nextCursor; } while (cursor); await this.restoreNotebookParents(scope,notebookParents); - if (this.hasTreeSubscription(scope)) await this.options.db.transaction((tx) => applyNativePersonalTree(tx,tree,this.options.userId,true)); + if (this.hasTreeSubscription(scope)) await this.options.db.transaction(async (tx) => { + await applyNativePersonalTree(tx,tree,this.options.userId,true); + // Persist the negotiated graph and subscription intent together. A crash + // after the server's ACK must not allow a silent legacy downgrade. + await tx.run(`INSERT INTO native_runtime_meta (key,value,updatedAt) VALUES (?,?,?) + ON CONFLICT(key) DO UPDATE SET value=excluded.value,updatedAt=excluded.updatedAt`, + [nativeTreeSubscriptionKey(this.options.profileId),NATIVE_TREE_PENDING_SUBSCRIPTION,now()]); + }); if(scope.workspaceId)await this.pruneWorkspaceSnapshot(scope,seen); await this.advance(scope,sequence,this.hasTreeSubscription(scope)); } @@ -484,9 +491,10 @@ export class MobileSyncEngine { private async negotiateTree(scope: ScopeDescriptor): Promise { if (scope.workspaceId || this.treeNegotiated) return; - const bound = (await this.options.db.query<{value:string}>( + const stored = (await this.options.db.query<{value:string}>( "SELECT value FROM native_runtime_meta WHERE key=?", [nativeTreeSubscriptionKey(this.options.profileId)], - ))[0]?.value === NATIVE_TREE_ENTITY_SET; + ))[0]?.value; + const bound = stored === NATIVE_TREE_ENTITY_SET || stored === NATIVE_TREE_PENDING_SUBSCRIPTION; const params = new URLSearchParams({scopeKey:scope.scopeKey,entityTypes:NATIVE_TREE_ENTITY_SET,deviceId:this.options.deviceId}); let supported = false; try { diff --git a/frontend/src/lib/nativeKnowledgeTreeSync.ts b/frontend/src/lib/nativeKnowledgeTreeSync.ts index c63bf5280..324dd25e8 100644 --- a/frontend/src/lib/nativeKnowledgeTreeSync.ts +++ b/frontend/src/lib/nativeKnowledgeTreeSync.ts @@ -3,6 +3,7 @@ import type { NativeDatabase } from "./nativeDatabase"; // Matches the existing server's complete negotiated set. Partial subscriptions // are rejected by Sync V2; workspace clients retain their legacy subscription. export const NATIVE_TREE_ENTITY_SET = "notebook,note,tag,note_tag,favorite,attachment,task,task_reminder,diary,mindmap,knowledge_tree_node"; +export const NATIVE_TREE_PENDING_SUBSCRIPTION = `pending:${NATIVE_TREE_ENTITY_SET}`; export function nativeTreeSubscriptionKey(profileId: string): string { return `nativeTreeSubscription:${profileId}:personal`; diff --git a/scripts/android-sync-acceptance.mjs b/scripts/android-sync-acceptance.mjs index e4aa85cba..c91b5a16e 100644 --- a/scripts/android-sync-acceptance.mjs +++ b/scripts/android-sync-acceptance.mjs @@ -89,24 +89,33 @@ try { await remoteEdit(ids[0], "reconnected"); await native("start"); await wait(async () => (await native("status"))[0].note.contentText === "reconnected", "reconnect did not catch up"); + let structureStarted = performance.now(); const folderResponse = await fetch(`${base}/acceptance/tree-folder`, {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({parentId:`note:${ids[0]}`})}); assert.ok(folderResponse.ok, `remote folder create failed: ${folderResponse.status}`); const folder = await folderResponse.json(); await wait(async () => (await native("structure",folder.id))?.parentId === `note:${ids[0]}`, "folder-under-document structure not pulled"); + const remoteFolderCreateMs = Math.round(performance.now()-structureStarted); + structureStarted = performance.now(); const moved = await fetch(`${base}/acceptance/tree-move/${encodeURIComponent(folder.id)}`, {method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({parentId:null,sortOrder:17})}); assert.ok(moved.ok, `remote tree move failed: ${moved.status}`); await wait(async () => {const row=await native("structure",folder.id);return row?.parentId === null && row.sortOrder === 17;}, "remote tree move not pulled"); + const remoteTreeMoveMs = Math.round(performance.now()-structureStarted); + await page.evaluate(() => window.acceptanceRealtime.disconnect()); + const movedWithoutNotice = await fetch(`${base}/acceptance/tree-move/${encodeURIComponent(folder.id)}`, {method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({parentId:`note:${ids[0]}`,sortOrder:19})}); + assert.ok(movedWithoutNotice.ok); + await wait(async () => {const row=await native("structure",folder.id);return row?.parentId === `note:${ids[0]}` && row.sortOrder === 19;}, "poll did not compensate for a missing tree notification",40_000); adb("reverse", "--remove", "tcp:47831"); await browser.close(); adb("shell", "am", "force-stop", "com.nowen.note.syncacceptance"); adb("shell", "am", "start", "-n", "com.nowen.note.syncacceptance/com.nowen.note.MainActivity"); await attach(); const restoredStructure = await native("structure",folder.id); - assert.equal(restoredStructure.parentId,null); assert.equal(restoredStructure.sortOrder,17); + assert.equal(restoredStructure.parentId,`note:${ids[0]}`); assert.equal(restoredStructure.sortOrder,19); assert.equal((await native("schema"))[0].user_version,6); const report = { device: serial, package: "com.nowen.note.syncacceptance", offlineImmediateRead: true, forceStopRecovery: true, knowledgeTreeRootCreate: true, readWriteThroughProductionApiBridge: true, negotiatedPersonalTreePull:true,remoteFolderUnderDocument:true,remoteTreeMove:true,structureForceStopRecovery:true,nativeSchemaVersion:6, + missingTreeNoticePoll:true,remoteFolderCreateMs,remoteTreeMoveMs, offlineNetworkIsolation: "adb reverse removed; HTTP server unreachable from Android", markdownAndRichText: true, missingNoticePoll: true, reconnect: true, httpPushToNativePull: stats(download), nativeCommitToServerAndAck: stats(upload), errors: await native("errors"), From 5428b6d7589674f9b32b5a36eaefe6de69502388 Mon Sep 17 00:00:00 2001 From: cropflre <767308865@qq.com> Date: Sun, 11 Oct 2026 16:36:30 +0800 Subject: [PATCH 05/13] fix(tree): preserve hidden root containers when moving child nodes --- .../data-consistency-contract-ci.yml | 4 +++ .../services/knowledgeTreeRootDocuments.ts | 13 +++++++++- .../knowledge-tree-root-documents.test.ts | 25 +++++++++++++++++++ .../data-consistency-contract.matrix.json | 3 ++- 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/.github/workflows/data-consistency-contract-ci.yml b/.github/workflows/data-consistency-contract-ci.yml index e47807c4e..7254f1c10 100644 --- a/.github/workflows/data-consistency-contract-ci.yml +++ b/.github/workflows/data-consistency-contract-ci.yml @@ -14,6 +14,8 @@ on: - "backend/src/routes/offline-sync*" - "backend/src/routes/sync*" - "backend/src/routes/knowledge-tree.ts" + - "backend/src/services/knowledgeTreeRootDocuments.ts" + - "backend/tests/knowledge-tree-root-documents.test.ts" - "backend/src/services/backup*" - "backend/src/runtime/backup*" - "backend/tests/sync-v2-*.test.ts" @@ -56,6 +58,8 @@ on: - "backend/src/routes/offline-sync*" - "backend/src/routes/sync*" - "backend/src/routes/knowledge-tree.ts" + - "backend/src/services/knowledgeTreeRootDocuments.ts" + - "backend/tests/knowledge-tree-root-documents.test.ts" - "backend/src/services/backup*" - "backend/src/runtime/backup*" - "backend/tests/sync-v2-*.test.ts" diff --git a/backend/src/services/knowledgeTreeRootDocuments.ts b/backend/src/services/knowledgeTreeRootDocuments.ts index a424bda56..575f5416d 100644 --- a/backend/src/services/knowledgeTreeRootDocuments.ts +++ b/backend/src/services/knowledgeTreeRootDocuments.ts @@ -199,7 +199,18 @@ export function moveKnowledgeNode(input: { sortOrder?: number; db?: Database.Database; }): KnowledgeTreeNode { - if (input.parentId !== null) return moveKnowledgeNodeBase(input); + if (input.parentId !== null) { + const db = input.db || getDb(); + const parentContainer = rootDocumentContainerForParent(db,input.parentId); + if (!parentContainer) return moveKnowledgeNodeBase(input); + // Match creation under a root document: the compatibility notebook must + // exist during business-parent validation, then stay hidden after commit. + return db.transaction(() => { + const container = activateRootContainer(db,parentContainer.ownerUserId,parentContainer.workspaceId); + try { return moveKnowledgeNodeBase({...input,db}); } + finally { hideRootContainer(db,container.notebookId,container.nodeId); } + })(); + } const db = input.db || getDb(); ensureKnowledgeTreeTables(db); diff --git a/backend/tests/knowledge-tree-root-documents.test.ts b/backend/tests/knowledge-tree-root-documents.test.ts index 0a08e21f9..06d056fa6 100644 --- a/backend/tests/knowledge-tree-root-documents.test.ts +++ b/backend/tests/knowledge-tree-root-documents.test.ts @@ -14,6 +14,31 @@ test.after(() => { delete process.env.DB_PATH; }); +test("moving folders and documents into root documents retains hidden containers and permission checks", async () => { + await import("../src/runtime/knowledge-tree-migration-bootstrap.js"); + await import("../src/runtime/knowledge-tree.js"); + const {getDb,closeDb} = await import("../src/db/schema.js"); closeDatabase = closeDb; + const {createKnowledgeChild,moveKnowledgeNode,ROOT_DOCUMENT_NOTEBOOK_PREFIX} = await import("../src/services/knowledgeTree.js"); + const db = getDb(); + db.prepare("INSERT INTO users (id,username,passwordHash) VALUES ('move-owner','move-owner','hash')").run(); + db.prepare("INSERT INTO workspaces (id,name,ownerId) VALUES ('move-ws','Move workspace','move-owner')").run(); + db.prepare("INSERT INTO workspace_members (workspaceId,userId,role) VALUES ('move-ws','move-owner','owner')").run(); + db.prepare("INSERT OR IGNORE INTO users (id,username,passwordHash) VALUES ('stranger','stranger','hash')").run(); + for (const workspaceId of [null,"move-ws"]) { + const root = createKnowledgeChild({actorUserId:"move-owner",workspaceId,parentId:null,nodeType:"markdown",title:"Target root",db}); + const folder = createKnowledgeChild({actorUserId:"move-owner",workspaceId,parentId:null,nodeType:"folder",title:"Moving folder",db}); + const child = createKnowledgeChild({actorUserId:"move-owner",workspaceId,parentId:folder.id,nodeType:"note",title:"Moving document",db}); + const hiddenId = (db.prepare("SELECT notebookId FROM notes WHERE id=?").get(root.resourceId) as {notebookId:string}).notebookId; + assert.throws(() => moveKnowledgeNode({actorUserId:"stranger",nodeId:folder.id,parentId:root.id,db})); + assert.equal((db.prepare("SELECT isDeleted FROM notebooks WHERE id=?").get(hiddenId) as {isDeleted:number}).isDeleted,1); + assert.equal(moveKnowledgeNode({actorUserId:"move-owner",nodeId:folder.id,parentId:root.id,db}).parentId,root.id); + assert.equal(moveKnowledgeNode({actorUserId:"move-owner",nodeId:child.id,parentId:root.id,db}).parentId,root.id); + assert.equal((db.prepare("SELECT isDeleted FROM notebooks WHERE id=?").get(hiddenId) as {isDeleted:number}).isDeleted,1); + assert.equal(db.prepare("SELECT id FROM knowledge_tree_nodes WHERE id=?").get(`notebook:${hiddenId}`),undefined); + assert.ok(hiddenId.startsWith(ROOT_DOCUMENT_NOTEBOOK_PREFIX)); + } +}); + test("knowledge tree creates rich-text and Markdown documents at root", async () => { await import("../src/runtime/knowledge-tree-migration-bootstrap.js"); await import("../src/runtime/knowledge-tree.js"); diff --git a/docs/architecture/data-consistency-contract.matrix.json b/docs/architecture/data-consistency-contract.matrix.json index 8a5ae7ff1..4f0181c41 100644 --- a/docs/architecture/data-consistency-contract.matrix.json +++ b/docs/architecture/data-consistency-contract.matrix.json @@ -297,7 +297,8 @@ { "file": "frontend/src/lib/encryptedNotes/__tests__/nativeDatabaseInitialization.test.ts", "case": "rolls back a failed schema 5 tree migration" }, { "file": "backend/tests/sync-v2-mobile-multidevice.test.ts", "case": "lost initial tree ACK retains subscription intent" }, { "file": "backend/tests/sync-v2-mobile-multidevice.test.ts", "case": "REST tree movement emits a committed metadata-only Sync V2 notice" }, - { "file": "frontend/src/lib/__tests__/mobileNativeTreeSubscription.test.ts", "case": "preserves subscription intent after a lost initial ACK" } + { "file": "frontend/src/lib/__tests__/mobileNativeTreeSubscription.test.ts", "case": "preserves subscription intent after a lost initial ACK" }, + { "file": "backend/tests/knowledge-tree-root-documents.test.ts", "case": "moving folders and documents into root documents retains hidden containers" } ] } ] From 245949725f4cb588aed15cdaa40e9143026f1511 Mon Sep 17 00:00:00 2001 From: cropflre <767308865@qq.com> Date: Sun, 11 Oct 2026 16:41:52 +0800 Subject: [PATCH 06/13] docs(sync): record native tree pull and Android recovery acceptance --- CHANGELOG.md | 1 + .../sync-v2-native-tree-pull-validation.en.md | 17 +++++++ docs/sync-v2-native-tree-pull-validation.md | 47 +++++++++++++++++++ docs/sync-v2-native-tree-validation.md | 2 + docs/sync-v2-reliability-validation.md | 2 + docs/sync-v2-reliability.en.md | 2 + .../sync-v2-native-tree-pull-2026-10-11.json | 44 +++++++++++++++++ scripts/android-sync-acceptance.mjs | 2 +- 8 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 docs/sync-v2-native-tree-pull-validation.en.md create mode 100644 docs/sync-v2-native-tree-pull-validation.md create mode 100644 docs/test-results/sync-v2-native-tree-pull-2026-10-11.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 9413188f4..08f200fd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ ## 未发布 / Unreleased +- **sync**:Native 个人空间协商接收完整知识树结构,事务保存跨文档父子关系、移动与删除;升级重建订阅、首次 ACK 丢失保留基线。REST 树修改提交后唤醒 Pull,并修复移入根文档时隐藏容器校验失败。Receive negotiated personal tree structure on native clients, retain subscription intent across lost ACKs, notify after REST commits, and preserve hidden containers during root-document moves. Native structural writes and conflict UI remain pending. - **android**:个人知识树根级和普通文件夹内的 Markdown/富文本可先保存到本机并进入现有 Sync V2;将已授权的个人远端正文持久化,保护重启和后续离线编辑。Persist personal native tree documents and authorized missing bodies before subsequent offline edits; full shared/protected tree synchronization remains incomplete. - **sync**:保护 Native 离线编辑、删除冲突和迟到 ACK;重试保留 mutationId,避免旧更新复活已删除笔记。Protect durable native edits, deletion conflicts and late ACKs without changing mutation identity. - **android**:复用 WebSocket 唤醒 Sync V2 Pull,提交后刷新界面,并通过前台周期 Pull 补偿丢失通知。Wake native Pull on notices/reconnect and refresh UI after committed Apply. diff --git a/docs/sync-v2-native-tree-pull-validation.en.md b/docs/sync-v2-native-tree-pull-validation.en.md new file mode 100644 index 000000000..28482c59b --- /dev/null +++ b/docs/sync-v2-native-tree-pull-validation.en.md @@ -0,0 +1,17 @@ +# Native personal tree structure reception and recovery + +2026-10-11, third reliability batch on `codex/native-knowledge-tree-offline`, continuing Draft PR #831 into `release/v1.5.2`. Baseline: `1b5b0c79`. [Chinese evidence](sync-v2-native-tree-pull-validation.md). + +Native previously rejected tree entities and could only project legacy notebook parents. The engine now negotiates the existing complete entity set through `/plan`, with a stable device ID, rebuilds a paginated personal Snapshot, then receives structural changes through the existing Pull path. Workspace subscriptions remain legacy. A fresh profile can use an old server; a persisted or pending tree subscription never silently downgrades. + +Schema 6 adds structural metadata without widening the locally writable Outbox registry. Snapshot replaces only remote tree metadata. Final-graph validation checks ownership, scope, resource identity/dependencies, types, ordering, parents and cycles. Invalid incremental structure rolls back business and tree changes together, before ACK. Bootstrap business pages retain their existing separate transactions; the complete graph and pending subscription intent commit together before the first ACK. An ambiguous ACK forces baseline replay after restart. Migration failure rolls back without advancing the schema version. + +Actual Android acceptance found two additional defects. REST tree writes committed without waking the Native engine; successful committed structural Feed changes now reuse metadata-only `sync.changed` notices. Moving a folder back under a root document failed with `NOTEBOOK_PARENT_NOT_FOUND`; the root-document wrapper now reuses creation's temporary container activation/hiding transaction, retaining permissions and scope checks. + +Final local validation: 308 backend tests and 243 frontend tests across 32 files passed, zero skips. Both production builds and TypeScript checks passed. Lint found 1447 historical errors and zero new errors. The contract gate verified 12 invariants, 82 references, 8 backend files and 18 frontend files. Full Voice CI frontend coverage includes the repaired getBaseUrl fixture. Earlier backend startup failures were caused by running from the repository root; the final complete run used the required backend working directory. + +Android API 35 / WebView 124.0.6367.219 ran the isolated `com.nowen.note.syncacceptance` APK. Offline document creation/read, forced-stop recovery, bidirectional body/receipt sync, reconnection, missing-notice compensation, folder-under-document reception, remote movement and offline structural recovery passed. The original temporary database and Outbox were preserved throughout retries. No physical user phone was installed. The crash buffer contained zero lines. Schema 5 migration was unit-tested; real old-user database upgrade acceptance was not performed. + +Thirty loopback samples per direction measured P50/P95/P99 303/394/406 ms for remote Push to Native API read and 449/509/540 ms for Native edit to server content plus persisted ACK. Single structural create/move measurements were 284/262 ms. These include authorization/query overhead and do not establish WAN, full-editor or structural percentile performance. [Machine report](test-results/sync-v2-native-tree-pull-2026-10-11.json) records package/source/hash boundaries. + +Commits: `a732b22e` reception/migration; `0e5b2e71` notifications and pending subscription protection; `5428b6d7` root-document move recovery. The graph is not an ACL/password proof or an offline authorization cache. Native structural writes, transactional tree Outbox, conflict choices, full editor acceptance, protected/shared trees, browser Local-first, attachment bytes, WAN and other platform/package acceptance remain pending. Reuse the server's structural base-field conflict contract in the next batch. Issue #829 remains open; no merge or release was performed. diff --git a/docs/sync-v2-native-tree-pull-validation.md b/docs/sync-v2-native-tree-pull-validation.md new file mode 100644 index 000000000..0bc271323 --- /dev/null +++ b/docs/sync-v2-native-tree-pull-validation.md @@ -0,0 +1,47 @@ +# Native 个人知识树结构接收与恢复 + +2026-10-11,同步可靠性第三批,基准 `1b5b0c79`;分支 `codex/native-knowledge-tree-offline`,继续 PR #831,目标 `release/v1.5.2`。[English](sync-v2-native-tree-pull-validation.en.md)。 + +## 问题与实际调用链 + +Native 原先只支持旧协议 10 类实体,收到 `knowledge_tree_node` 会拒绝;业务 `notebooks.parentId` 无法表达文件夹挂在文档下。只开启协议枚举会继承旧游标并漏掉历史结构。本批先接入个人空间接收基线,为后续本地结构写入提供可靠依据。 + +`MobileSyncEngine → /plan 协商完整既有实体集与 deviceId → 分页 /snapshot(业务资源先行)→ 整份结构事务校验/保存 → ACK → 本地订阅标记和游标 → /changes + /snapshot → 同批业务/结构 Apply → ACK`。工作区保留旧协议;新 Profile 遇到旧服务端可继续旧协议,已接收树的 Profile 不静默降级。 + +Native schema 6 新增 `native_knowledge_tree_nodes`,保存完整 parentId、顺序、回收状态与稳定资源身份;不改 Outbox/Conflict 的旧实体约束,不清除业务数据。完整树替换及 `pending:` 订阅意图同事务提交;首次 ACK 丢失时重启重新 Snapshot,避免继承旧协议高水位。DDL 失败回滚且不推进 user_version。 + +结构 Apply 校验账号、空间、资源身份、业务依赖、节点类型、整数顺序、完整最终父链和循环。同批非法结构回滚正文与结构,客户端/服务端游标不提前推进;先校验最终图允许合法的祖先反转。Snapshot 替换只清理远端结构元数据,不删除本地正文、附件或 Outbox。首次 Bootstrap 的业务分页仍分别提交,整份树在最后一次事务提交;不宣称整份业务 Snapshot 为单事务。 + +Android 首次验收发现 REST 树移动已提交但没有 `sync.changed`,客户端只等前台 30 秒补偿。`knowledge-tree` 路由现在在成功请求提交后,按新结构 Feed 所属空间复用既有通知器;通知只含 type/scope/fingerprint/sequence,失败和权限拒绝不发送。后续验收又复现 `NOTEBOOK_PARENT_NOT_FOUND`:移入根文档时隐藏笔记本未激活。移动入口现在复用创建入口的激活/隐藏事务,保留原 ACL、空间和循环校验,个人/工作区均有回归。 + +## 验证证据 + +| 验证 | 最终结果 | +| --- | --- | +| 后端全部 Sync V2、知识树、加密 Sync/Native 存储 | 308/308,0 skip;从 backend 目录执行 | +| 前端契约映射、完整 Voice CI 套件、Native SQLite 开关/迁移等 32 文件 | 243/243,0 skip;Node 24.19.0 | +| 结构专项 | 多设备、重开、旧游标升级、ACK 丢失、错误回滚、非法 scope/identity、父链反转、文件 metadata、230 文件夹分页及增量删除 | +| 构建 | 正常前端/后端、双方 TypeScript、Capacitor 测试包 Gradle 均通过 | +| lint / 契约 | 1447 历史问题,0 新增;12 invariants / 82 references / 8 backend files / 18 frontend files | +| Android | API 35,WebView 124.0.6367.219,独立包 com.nowen.note.syncacceptance;崩溃缓冲 0 行 | + +Windows 后端采用项目 Electron RunAsNode,以匹配 better-sqlite3 ABI;不重装共享依赖。前端调用 Node 24 的 Vitest 实际运行 SQLite 用例,不以旧 Node 跳过代替通过。扩大后端回归首次从仓库根目录运行,两个启动用例因 cwd/tsx 解析失败;改用 backend 目录后完整通过。PR #831 旧 `1b5b0c79` 的 Voice CI mock 缺少 getBaseUrl,已复现并补齐,完整语音套件包含在最终前端回归中。旧 i18n 基准缺键见上一批记录;新 SHA 的 CI 以 GitHub 实际结果为准。 + +设备验收使用原临时服务数据库和原测试账号 Outbox 连续重试,保留两次失败日志,没有清库。最终包前端源 `0e5b2e71`、服务端源 `5428b6d7`;APK SHA256 `e63ff8d48d10e1489257fea167ef132620f441ec6b420103a5ccae82612fe0e3`。测试专用入口不进入正常 Vite 构建;连接的 PJZ110 没有安装或测试。 + +实际验证:离线根级 Markdown/富文本立即读取、强制关闭恢复、双向正文与收据、通知丢失补偿、重连;通过生产 REST 创建文件夹于根文档下,接收移动到根及再次移回文档,切断通知后等待同一引擎周期 Pull,断网强制关闭后读取已持久化结构。schema 5→6 迁移由单测校验;本轮不宣称真实旧用户库的设备升级验收。 + +| 回环样本 | P50 / P95 / P99 | +| --- | --- | +| HTTP Push → Native API 读取,30 次 | 303 / 394 / 406 ms | +| Native 编辑 → 服务端正文 + Native ACK,30 次 | 449 / 509 / 540 ms | + +远端文件夹创建和移动各测一次,为 284 ms、262 ms;不将两个样本描述为结构延迟分位数。30 次的小样本 P99 为最大值,包含 API 权限查询和轮询开销,不代表公网或完整编辑器延迟。[机器记录](test-results/sync-v2-native-tree-pull-2026-10-11.json)。 + +## 提交与下一步 + +- `a732b22e`:个人结构订阅、schema 6、事务 Apply 与对应回归/CI。 +- `0e5b2e71`:REST 提交通知、首次树 ACK 持久化保护及补偿验收。 +- `5428b6d7`:根文档下移动时隐藏容器校验修复。 + +本轮完成接收基础。该结构表不含 ACL/密码证明,不用于授权、更新离线权限快照或显示云端写入收据;知识树列表仍使用既有服务端/权限缓存 Bridge。Native 主动文件夹创建、移动/排序/删除/恢复及结构冲突 UI 尚未接入;下一批应复用现有服务端 baseParentId/baseSortOrder/baseIsDeleted 冲突契约,补原子 Outbox 与本地/远端选择,再做手机编辑器验收。共享/密码、新旧权限变化、手机浏览器、附件 bytes、WAN、iOS/HarmonyOS、NAS 与正式包仍需专项覆盖;不关闭 #829,不合并或发布。 diff --git a/docs/sync-v2-native-tree-validation.md b/docs/sync-v2-native-tree-validation.md index a80705940..541883080 100644 --- a/docs/sync-v2-native-tree-validation.md +++ b/docs/sync-v2-native-tree-validation.md @@ -2,6 +2,8 @@ 2026-10-11,基准 `856d42bf`,分支 `codex/native-knowledge-tree-offline`,目标 `release/v1.5.2`。本轮是同步可靠性第二批,所有测试内容均为临时模拟数据。 +后续第三批已接入个人树结构接收;本文件保留第二批当时的边界,最新能力与验收见 [Native tree Pull validation](sync-v2-native-tree-pull-validation.md)。 + ## 问题、决策与执行链路 Issue #829 的 Native 调用链存在可验证缺口:登录态知识树 `POST /knowledge-tree/nodes` 成功后,编辑器改从 Native Repository 读取。原 fallback 只返回远端正文,随后保存仍可能走远端,离线重开未具备持久化正文。手机浏览器也在 Issue 的报告范围内,但本轮不宣称其 IndexedDB/REST 路径已修复。 diff --git a/docs/sync-v2-reliability-validation.md b/docs/sync-v2-reliability-validation.md index 2b7a5c039..9ef1b1333 100644 --- a/docs/sync-v2-reliability-validation.md +++ b/docs/sync-v2-reliability-validation.md @@ -1,5 +1,7 @@ # Sync V2 reliability validation / 同步可靠性验证 +第三批更新:个人 Native 结构接收、REST 提交通知、根文档移动恢复,以及 Android 新包证据见 [Native tree Pull validation](sync-v2-native-tree-pull-validation.md)。完整本地结构写入与冲突操作尚未接入。 + 第二批更新:2026-10-11 的 Native 个人根级/普通文件夹 MD/富文本创建、已授权个人正文持久化,以及新包 API Bridge 强杀恢复证据见 [Native tree validation](sync-v2-native-tree-validation.md)。下文保留第一批的原始结果与当时缺口;完整 Native 树结构和浏览器 Local-first 仍未完成。 日期:2026-10-11。基准 `d95d3787`;开发分支 `codex/sync-v2-reliability`。所有笔记正文均为临时测试数据。 diff --git a/docs/sync-v2-reliability.en.md b/docs/sync-v2-reliability.en.md index efab0d419..8fdfdc2da 100644 --- a/docs/sync-v2-reliability.en.md +++ b/docs/sync-v2-reliability.en.md @@ -1,5 +1,7 @@ # Sync V2 reliability: implementation and acceptance +Third-batch evidence: [Native personal tree Pull](sync-v2-native-tree-pull-validation.en.md) adds negotiated structural reception, durable subscription recovery, committed REST notifications and root-document move repair. Native structural writes and conflict UI remain pending; the rest of this document retains the original first-batch record. + This branch improves the existing Sync V2 protocol; it does not replace it with another synchronization system. It is a **Draft candidate**, not completed production acceptance. Base: `release/v1.5.2` at `d95d3787`; branch: `codex/sync-v2-reliability`. The [audit](sync-v2-reliability-audit.md) traces each platform's actual storage and synchronization path. The [validation record](sync-v2-reliability-validation.md) contains the complete entity/platform matrix, failure evidence, commands and limitations. diff --git a/docs/test-results/sync-v2-native-tree-pull-2026-10-11.json b/docs/test-results/sync-v2-native-tree-pull-2026-10-11.json new file mode 100644 index 000000000..0f3fcf02a --- /dev/null +++ b/docs/test-results/sync-v2-native-tree-pull-2026-10-11.json @@ -0,0 +1,44 @@ +{ + "device": "emulator-5580", + "package": "com.nowen.note.syncacceptance", + "frontendSourceCommit": "0e5b2e713fd9e5a87c4d0827f55fac0d83fe0243", + "backendSourceCommit": "5428b6d7", + "apkSha256": "e63ff8d48d10e1489257fea167ef132620f441ec6b420103a5ccae82612fe0e3", + "androidApi": 35, + "webViewVersion": "124.0.6367.219", + "nativeCrashBufferLines": 0, + "offlineImmediateRead": true, + "forceStopRecovery": true, + "knowledgeTreeRootCreate": true, + "readWriteThroughProductionApiBridge": true, + "negotiatedPersonalTreePull": true, + "remoteFolderUnderDocument": true, + "remoteTreeMove": true, + "structureForceStopRecovery": true, + "nativeSchemaVersion": 6, + "missingTreeNoticePoll": true, + "remoteFolderCreateMs": 284, + "remoteTreeMoveMs": 262, + "offlineNetworkIsolation": "adb reverse removed; HTTP server unreachable from Android", + "markdownAndRichText": true, + "missingNoticePoll": true, + "reconnect": true, + "httpPushToNativePull": { + "samples": 30, + "p50Ms": 303, + "p95Ms": 394, + "p99Ms": 406 + }, + "nativeCommitToServerAndAck": { + "samples": 30, + "p50Ms": 449, + "p95Ms": 509, + "p99Ms": 540 + }, + "errors": [ + { + "lastError": null + } + ], + "boundary": "Loopback adb reverse; real Android Native SQLite, personal tree create/read/update and negotiated structural Pull. Remote folder creation and movement use the production knowledge tree routes. Native structural writes/conflict UI, full EditorPane, protected/shared ACL, attachment bytes and full production JWT middleware are not accepted here." +} diff --git a/scripts/android-sync-acceptance.mjs b/scripts/android-sync-acceptance.mjs index c91b5a16e..cb6bf91a6 100644 --- a/scripts/android-sync-acceptance.mjs +++ b/scripts/android-sync-acceptance.mjs @@ -126,6 +126,6 @@ try { } finally { if (page) await native("stop").catch(() => undefined); await browser?.close().catch(() => undefined); - try { adb("reverse", "--remove", "tcp:47831"); } catch { /* Already removed for offline structural recovery. */ } + if (adb("reverse", "--list").includes("tcp:47831")) adb("reverse", "--remove", "tcp:47831"); } process.exit(0); From 053b2ae1e91aafb7594c46637139e8db3bae5fc7 Mon Sep 17 00:00:00 2001 From: cropflre <767308865@qq.com> Date: Sun, 11 Oct 2026 19:48:14 +0800 Subject: [PATCH 07/13] feat(sync): persist native personal folder moves and recover structural conflicts --- .github/workflows/android-local-first-ci.yml | 12 +- backend/src/sync/knowledgeTreeStructure.ts | 8 ++ backend/tests/sync-v2-android-server.ts | 1 + .../tests/sync-v2-mobile-multidevice.test.ts | 111 ++++++++++++++++ .../data-consistency-contract.matrix.json | 16 +++ .../mobileLocalFirstPasswordAccess.test.ts | 1 + .../mobileLocalKnowledgeTreeBridge.test.ts | 1 + .../mobileNativeTreeWritesBridge.test.ts | 60 +++++++++ .../mobileSignedInTreeCreate.test.ts | 1 + .../nativeTreeSchemaMigration.test.ts | 68 ++++++++++ .../nativeDatabaseInitialization.test.ts | 16 +-- .../src/lib/mobileLocalKnowledgeTreeBridge.ts | 46 ++++++- frontend/src/lib/mobileSyncEngine.ts | 100 ++++++++++++++- frontend/src/lib/nativeDatabase.ts | 10 +- frontend/src/lib/nativeKnowledgeTreeSync.ts | 39 +++--- frontend/src/lib/nativeLocalRepository.ts | 17 +++ frontend/src/lib/nativePersonalTreeWrites.ts | 119 ++++++++++++++++++ frontend/tests/android-sync/main.ts | 8 ++ scripts/android-sync-acceptance.mjs | 34 ++++- 19 files changed, 630 insertions(+), 38 deletions(-) create mode 100644 frontend/src/lib/__tests__/mobileNativeTreeWritesBridge.test.ts create mode 100644 frontend/src/lib/__tests__/nativeTreeSchemaMigration.test.ts create mode 100644 frontend/src/lib/nativePersonalTreeWrites.ts diff --git a/.github/workflows/android-local-first-ci.yml b/.github/workflows/android-local-first-ci.yml index 7ca2cd7d3..337b10776 100644 --- a/.github/workflows/android-local-first-ci.yml +++ b/.github/workflows/android-local-first-ci.yml @@ -11,6 +11,10 @@ on: - "frontend/src/lib/realtime.ts" - "frontend/src/lib/mobileSyncEngine.ts" - "frontend/src/lib/nativeKnowledgeTreeSync.ts" + - "frontend/src/lib/nativePersonalTreeWrites.ts" + - "frontend/src/lib/nativeLocalRepository.ts" + - "frontend/src/lib/__tests__/mobileNativeTreeWritesBridge.test.ts" + - "frontend/src/lib/__tests__/nativeTreeSchemaMigration.test.ts" - "frontend/src/lib/nativeDatabase.ts" - "frontend/src/lib/__tests__/mobileNativeTreeSubscription.test.ts" - "frontend/src/lib/encryptedNotes/__tests__/nativeDatabaseInitialization.test.ts" @@ -33,6 +37,10 @@ on: - "frontend/src/lib/realtime.ts" - "frontend/src/lib/mobileSyncEngine.ts" - "frontend/src/lib/nativeKnowledgeTreeSync.ts" + - "frontend/src/lib/nativePersonalTreeWrites.ts" + - "frontend/src/lib/nativeLocalRepository.ts" + - "frontend/src/lib/__tests__/mobileNativeTreeWritesBridge.test.ts" + - "frontend/src/lib/__tests__/nativeTreeSchemaMigration.test.ts" - "frontend/src/lib/nativeDatabase.ts" - "frontend/src/lib/__tests__/mobileNativeTreeSubscription.test.ts" - "frontend/src/lib/encryptedNotes/__tests__/nativeDatabaseInitialization.test.ts" @@ -60,7 +68,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 cache: npm cache-dependency-path: frontend/package-lock.json @@ -87,6 +95,8 @@ jobs: src/lib/__tests__/mobileSyncUnexpectedFailure.test.ts src/lib/__tests__/mobileSyncEndpoint.test.ts src/lib/__tests__/mobileNativeTreeSubscription.test.ts + src/lib/__tests__/nativeTreeSchemaMigration.test.ts + src/lib/__tests__/mobileNativeTreeWritesBridge.test.ts src/lib/encryptedNotes/__tests__/nativeDatabaseInitialization.test.ts src/components/__tests__/AccountLoginHistory.test.tsx src/components/__tests__/AccountLoginHistoryServerEdit.test.tsx diff --git a/backend/src/sync/knowledgeTreeStructure.ts b/backend/src/sync/knowledgeTreeStructure.ts index 8e2ea6850..9bec146c7 100644 --- a/backend/src/sync/knowledgeTreeStructure.ts +++ b/backend/src/sync/knowledgeTreeStructure.ts @@ -2,6 +2,7 @@ import type Database from "better-sqlite3"; import { hasKnowledgeCapability, resolveKnowledgeNodeAccess } from "../services/knowledgeCapabilities.js"; import { SyncError } from "./errors.js"; import { resolveAuthorizedScope } from "./scope.js"; +import { moveKnowledgeNode } from "../services/knowledgeTreeRootDocuments.js"; interface TreeRow { id: string; @@ -146,6 +147,13 @@ export function applyKnowledgeTreeStructureMutation( throw new SyncError("INVALID_PAYLOAD", "知识树不能形成循环"); } + if (node.resourceType === "notebook" && !node.isDeleted + && (desired.parentId !== current.parentId || desired.sortOrder !== current.sortOrder)) { + // Keep the legacy notebook container in the same server transaction. The + // existing move service also handles root-document hidden containers. + moveKnowledgeNode({db,actorUserId:input.userId,nodeId:node.id,parentId:desired.parentId,sortOrder:desired.sortOrder}); + db.prepare("UPDATE notebooks SET sortOrder=? WHERE id=?").run(desired.sortOrder,node.resourceId); + } db.prepare(` UPDATE knowledge_tree_nodes SET parentId = ?, sortOrder = ?, isDeleted = ?, deletedAt = ?, updatedAt = datetime('now') diff --git a/backend/tests/sync-v2-android-server.ts b/backend/tests/sync-v2-android-server.ts index 91bcbc9ec..14e9d59b7 100644 --- a/backend/tests/sync-v2-android-server.ts +++ b/backend/tests/sync-v2-android-server.ts @@ -35,6 +35,7 @@ async function main() { // Match index.ts's trailing-slash mount for the list entry point. app.route("/api/knowledge-tree/", treeRoutes); app.get("/acceptance/note/:id", (c) => c.json(db.prepare("SELECT * FROM notes WHERE id=? AND userId=?").get(c.req.param("id"), userId) || null)); + app.get("/acceptance/tree/:id", (c) => c.json(db.prepare("SELECT id,parentId,sortOrder,isDeleted FROM knowledge_tree_nodes WHERE id=? AND userId=?").get(c.req.param("id"),userId) || null)); app.post("/acceptance/tree-folder", async (c) => { const {parentId} = await c.req.json<{parentId:string}>(); return app.request("http://localhost/api/knowledge-tree/nodes?workspaceId=personal", {method:"POST", diff --git a/backend/tests/sync-v2-mobile-multidevice.test.ts b/backend/tests/sync-v2-mobile-multidevice.test.ts index 354eb06c8..4789dad43 100644 --- a/backend/tests/sync-v2-mobile-multidevice.test.ts +++ b/backend/tests/sync-v2-mobile-multidevice.test.ts @@ -95,6 +95,117 @@ async function client(name: string): Promise { async function pending(c: Client) { return c.db.query<{ mutationId: string; baseVersion: number; payload: string }>("SELECT mutationId,baseVersion,payload FROM sync_outbox WHERE profileId=? ORDER BY createdAt,rowid", [c.profileId]); } async function conflicts(c: Client) { return c.db.query<{ localPayload: string; remotePayload: string }>("SELECT localPayload,remotePayload FROM sync_conflicts WHERE profileId=? AND status='unresolved'", [c.profileId]); } +async function cachePersonalTree(c: Client): Promise { + const response = await fetch(`${serverUrl}/api/knowledge-tree/?workspaceId=personal`, { headers: { Authorization: `Bearer ${USER}` } }); + assert.equal(response.status, 200); + const { nodes } = await response.json() as { nodes: unknown[] }; + await c.db.run(`INSERT INTO native_runtime_meta (key,value,updatedAt) VALUES ('knowledgeTreeSnapshot:v1:personal:0',?,'now') + ON CONFLICT(key) DO UPDATE SET value=excluded.value`, [JSON.stringify(nodes)]); +} + +async function movableTree(name: string) { + const c = await client(name), ids = [randomUUID(), randomUUID(), randomUUID()]; + for (const id of ids) await c.repository.notebooks.create({ id, name: "Move fixture" }); + const document = randomUUID(); + await c.repository.createPersonalTreeNote({ id: document, contentFormat: "markdown", content: "Preserved body" }, null); + await c.sync(); await cachePersonalTree(c); + return { c, node: `notebook:${ids[0]}`, left: `notebook:${ids[1]}`, right: `notebook:${ids[2]}`, document: `note:${document}` }; +} + +test("native personal moves and sorting are atomic, survive reopen and push only structural fields", async () => { + const { c, node, left, document } = await movableTree("active-offline"); + await c.repository.movePersonalTreeNodes([{ id: node, parentId: document, sortOrder: 23 }, { id: left, sortOrder: 24 }]); + const outbox = await pending(c); assert.equal(outbox.length, 2); + const mutation = JSON.parse(outbox[0].payload); + assert.equal(mutation.parentId, document); assert.equal(mutation.baseParentId, null); + assert.equal(mutation.content, undefined); assert.equal(mutation.title, undefined); + await c.db.close(); + const reopened = await client("active-offline"); + assert.deepEqual((await reopened.db.query("SELECT parentId,sortOrder FROM native_knowledge_tree_nodes WHERE id=?", [node]))[0], { parentId: document, sortOrder: 23 }); + await reopened.sync(); + assert.equal((await pending(reopened)).length, 0); + assert.deepEqual(serverDb.prepare("SELECT parentId,sortOrder FROM knowledge_tree_nodes WHERE id=?").get(node), { parentId: document, sortOrder: 23 }); + assert.equal(serverDb.prepare("SELECT sortOrder FROM knowledge_tree_nodes WHERE id=?").get(left).sortOrder, 24); + assert.equal((await reopened.repository.notes.get(document.slice(5)))?.content, "Preserved body"); + await reopened.repository.notebooks.update(node.slice(9),{name:"Rename after move"}); await reopened.sync(); + assert.equal(serverDb.prepare("SELECT parentId FROM knowledge_tree_nodes WHERE id=?").get(node).parentId,document); + assert.equal(serverDb.prepare("SELECT sortOrder FROM knowledge_tree_nodes WHERE id=?").get(node).sortOrder,23); + assert.equal(serverDb.prepare("SELECT parentId FROM notebooks WHERE id=?").get(node.slice(9)).parentId, + (await reopened.repository.notebooks.get(node.slice(9)))?.parentId); +}); + +for (const resolution of ["keep-local", "keep-remote"] as const) test(`concurrent native tree moves recover with ${resolution} and preserve latest local position`, async () => { + const { c: a, node, left, right } = await movableTree(`active-${resolution}`), b = await client(`active-peer-${resolution}`); + await b.sync(); await cachePersonalTree(b); + await a.repository.movePersonalTreeNodes([{ id: node, parentId: left, sortOrder: 11 }]); + await b.repository.movePersonalTreeNodes([{ id: node, parentId: right, sortOrder: 12 }]); + await a.sync(); await b.sync(); + const rows = await b.db.query<{id:string;remotePayload:string}>("SELECT id,remotePayload FROM sync_conflicts WHERE profileId=? AND entityId=? AND status='unresolved'", [b.profileId,node]); + assert.equal(rows.length, 1); assert.equal(JSON.parse(rows[0].remotePayload).parentId, left); + assert.equal((await b.db.query<{parentId:string}>("SELECT parentId FROM native_knowledge_tree_nodes WHERE id=?", [node]))[0].parentId, right); + await b.repository.movePersonalTreeNodes([{ id: node, parentId: right, sortOrder: 33 }]); + await b.engine.resolveLocalConflict(rows[0].id, resolution); await b.sync(); await a.sync(); + assert.equal((await conflicts(b)).length, 0); assert.equal((await pending(b)).length, 0); + const expected = { parentId: resolution === "keep-local" ? right : left, sortOrder: resolution === "keep-local" ? 33 : 11 }; + assert.deepEqual(serverDb.prepare("SELECT parentId,sortOrder FROM knowledge_tree_nodes WHERE id=?").get(node), expected); + assert.deepEqual((await a.db.query("SELECT parentId,sortOrder FROM native_knowledge_tree_nodes WHERE id=?", [node]))[0], expected); +}); + +test("lost tree Push ACK and a newer move during Push retain stable mutation identities", async () => { + const { c, node, left, right } = await movableTree("active-lost-ack"); + await c.repository.movePersonalTreeNodes([{ id: node, parentId: left, sortOrder: 3 }]); + const first = (await pending(c))[0].mutationId; + afterPush = async () => { + await c.repository.movePersonalTreeNodes([{ id: node, parentId: right, sortOrder: 4 }]); + return Response.json({code:"SERVER_ERROR"}, {status:503}); + }; + try { await c.sync(); } finally { afterPush = undefined; } + assert.equal((await pending(c))[0].mutationId, first); + await c.db.close(); const reopened = await client("active-lost-ack"); await reopened.sync(); + assert.equal((await pending(reopened)).length, 0); assert.equal((await conflicts(reopened)).length, 0); + assert.deepEqual(serverDb.prepare("SELECT parentId,sortOrder FROM knowledge_tree_nodes WHERE id=?").get(node), { parentId: right, sortOrder: 4 }); +}); + +test("native tree writes reject missing subscription, protected ancestry and cycles without partial commits", async () => { + const { c, node, left } = await movableTree("active-guards"); + await assert.rejects(c.repository.movePersonalTreeNodes([{ id: node, parentId: left }, { id: left, parentId: node }]), /循环/); + assert.equal((await pending(c)).length, 0); + const cached = (await c.db.query<{value:string}>("SELECT value FROM native_runtime_meta WHERE key='knowledgeTreeSnapshot:v1:personal:0'"))[0]; + const nodes = JSON.parse(cached.value) as Array<{id:string;isPasswordProtected?:number}>; + nodes.find((n) => n.id === left)!.isPasswordProtected = 1; + await c.db.run("UPDATE native_runtime_meta SET value=? WHERE key='knowledgeTreeSnapshot:v1:personal:0'", [JSON.stringify(nodes)]); + await assert.rejects(c.repository.movePersonalTreeNodes([{ id: node, parentId: left }]), /权限|密码/); + await c.db.run("DELETE FROM native_runtime_meta WHERE key=?", [`nativeTreeSubscription:${c.profileId}:personal`]); + await assert.rejects(c.repository.movePersonalTreeNodes([{ id: node, parentId: null }]), /订阅/); + assert.equal((await pending(c)).length, 0); + assert.equal((await c.db.query<{parentId:string|null}>("SELECT parentId FROM native_knowledge_tree_nodes WHERE id=?", [node]))[0].parentId, null); +}); + +test("a structural Outbox insert failure rolls back every move in the local batch", async () => { + const { c,node,left,right } = await movableTree("active-write-failure"); + await c.db.run(`CREATE TRIGGER reject_fixture_tree BEFORE INSERT ON sync_outbox + WHEN NEW.entityType='knowledge_tree_node' AND NEW.entityId='${left}' BEGIN SELECT RAISE(ABORT,'Outbox fixture failure'); END`); + await assert.rejects(c.repository.movePersonalTreeNodes([{id:node,parentId:right},{id:left,parentId:right}]), /Outbox fixture failure/); + assert.equal((await pending(c)).length,0); + assert.equal((await c.db.query<{parentId:string|null}>("SELECT parentId FROM native_knowledge_tree_nodes WHERE id=?",[node]))[0].parentId,null); +}); + +test("full tree rebuild preserves newer pending moves and recognizes an intermediate lost ACK echo", async () => { + const { c,node,left,right } = await movableTree("active-snapshot-pending"); + await c.repository.movePersonalTreeNodes([{id:node,parentId:left,sortOrder:1}]); + await c.db.run("UPDATE sync_state SET lastSequence=0 WHERE profileId=?",[c.profileId]); + afterPush = async (response) => { + const body = await response.json() as {serverSequence:number;results:Array<{mutationId:string}>}; + await c.repository.movePersonalTreeNodes([{id:node,parentId:right,sortOrder:2}]); + return Response.json({...body,results:body.results.map((r)=>({...r,status:"failed",code:"NETWORK_UNAVAILABLE"}))}); + }; + try { await c.sync(); } finally { afterPush=undefined; } + assert.equal((await conflicts(c)).length,0); + assert.equal((await c.db.query<{parentId:string}>("SELECT parentId FROM native_knowledge_tree_nodes WHERE id=?",[node]))[0].parentId,right); + await c.sync(); assert.equal((await pending(c)).length,0); assert.equal((await conflicts(c)).length,0); + assert.deepEqual(serverDb.prepare("SELECT parentId,sortOrder FROM knowledge_tree_nodes WHERE id=?").get(node),{parentId:right,sortOrder:2}); +}); + test.before(async () => { const schema = await import("../src/db/schema"); const routes = await import("../src/routes/sync-v2"); diff --git a/docs/architecture/data-consistency-contract.matrix.json b/docs/architecture/data-consistency-contract.matrix.json index 4f0181c41..c9945fe7f 100644 --- a/docs/architecture/data-consistency-contract.matrix.json +++ b/docs/architecture/data-consistency-contract.matrix.json @@ -22,6 +22,22 @@ "title": "本地业务写入与 Outbox 变更应在持久化后可恢复", "status": "covered", "checks": [ + { + "file": "backend/tests/sync-v2-mobile-multidevice.test.ts", + "case": "native personal moves and sorting are atomic, survive reopen and push only structural fields" + }, + { + "file": "backend/tests/sync-v2-mobile-multidevice.test.ts", + "case": "a structural Outbox insert failure rolls back every move in the local batch" + }, + { + "file": "frontend/src/lib/__tests__/nativeTreeSchemaMigration.test.ts", + "case": "migrates schema 6 queues without losing mutation identity, retries, versions or resolved history" + }, + { + "file": "frontend/src/lib/__tests__/nativeTreeSchemaMigration.test.ts", + "case": "rolls back both queue rebuilds and schema version when the second rebuild fails" + }, { "file": "backend/tests/sync-v2-outbox-capture.test.ts", "case": "创建笔记进入 Outbox,且 upsert 顺序在其笔记本之后" diff --git a/frontend/src/lib/__tests__/mobileLocalFirstPasswordAccess.test.ts b/frontend/src/lib/__tests__/mobileLocalFirstPasswordAccess.test.ts index 7f7fad1f5..608a61725 100644 --- a/frontend/src/lib/__tests__/mobileLocalFirstPasswordAccess.test.ts +++ b/frontend/src/lib/__tests__/mobileLocalFirstPasswordAccess.test.ts @@ -38,6 +38,7 @@ function setup(deviceOnly = false) { const authorize = vi.spyOn(api, "getNoteSlim").mockResolvedValue(notes[0]); const repository = { listLocalTreeDrafts: vi.fn(async () => []), + listPersonalTreeOverrides: vi.fn(async () => []), listNotebooksForWorkspace: vi.fn(async () => []), hydrateAuthorizedNote: vi.fn(async (incoming: Note) => incoming), notes: { get: vi.fn(async (id: string) => notes.find((item) => item.id === id)) }, diff --git a/frontend/src/lib/__tests__/mobileLocalKnowledgeTreeBridge.test.ts b/frontend/src/lib/__tests__/mobileLocalKnowledgeTreeBridge.test.ts index a93ccd152..58e5d922b 100644 --- a/frontend/src/lib/__tests__/mobileLocalKnowledgeTreeBridge.test.ts +++ b/frontend/src/lib/__tests__/mobileLocalKnowledgeTreeBridge.test.ts @@ -69,6 +69,7 @@ function createRepository() { ]; const repository = { + listPersonalTreeOverrides: vi.fn(async () => []), listLocalTreeDrafts: vi.fn(async () => []), listNotebooksForWorkspace: vi.fn(async () => notebooks), listNotesForWorkspace: vi.fn(async (_workspaceId?: string, query?: { includeTrashed?: boolean }) => ( diff --git a/frontend/src/lib/__tests__/mobileNativeTreeWritesBridge.test.ts b/frontend/src/lib/__tests__/mobileNativeTreeWritesBridge.test.ts new file mode 100644 index 000000000..005e37782 --- /dev/null +++ b/frontend/src/lib/__tests__/mobileNativeTreeWritesBridge.test.ts @@ -0,0 +1,60 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { knowledgeTreeApi, type KnowledgeTreeNode } from "../knowledgeTreeApi"; +import { installMobileLocalKnowledgeTreeBridge } from "../mobileLocalKnowledgeTreeBridge"; +import type { NativeLocalRepository } from "../nativeLocalRepository"; +import type { NativeDatabase } from "../nativeDatabase"; + +let restore: (() => void) | undefined; +afterEach(() => { restore?.(); vi.restoreAllMocks(); localStorage.removeItem("nowen-current-workspace"); }); +function node(id: string, parentId: string | null = null): KnowledgeTreeNode { + return { id,resourceId:id.slice(9),resourceType:"notebook",nodeType:"folder",parentId,userId:"u",workspaceId:null, + scopeKey:"personal:u",isDeleted:0,isPasswordProtected:0,sortOrder:0,childCount:0,isExpanded:1,createdAt:"now",updatedAt:"now",title:id, + access:{nodeId:id,source:"owner",rolePreset:"admin",sourceNodeId:null,capabilities:{canView:true,canMove:true,canCreate:true} as never} }; +} +function setup() { + localStorage.setItem("nowen-current-workspace","personal"); + const a = node("notebook:a"), b = node("notebook:b"); + const remoteMove = vi.spyOn(knowledgeTreeApi,"move").mockResolvedValue(a); + const remoteReorder = vi.spyOn(knowledgeTreeApi,"reorder").mockResolvedValue({success:true,updated:1}); + const remoteList = vi.spyOn(knowledgeTreeApi,"listForWorkspace").mockResolvedValue({nodes:[a,b]}); + const moves = vi.fn(async () => [{...a,parentId:b.id,sortOrder:7}]); + const overrides = vi.fn(async () => [] as Array>); + const repository = {movePersonalTreeNodes:moves,listPersonalTreeOverrides:overrides,listLocalTreeDrafts:async()=>[]} as unknown as NativeLocalRepository; + const metadata = new Map(); + const db = {run:async(_sql:string,values:unknown[])=>{metadata.set(String(values[0]),String(values[1]));return {changes:1};}, + query:async(_sql:string,values:unknown[])=>metadata.has(String(values[0]))?[{value:metadata.get(String(values[0]))}]:[]} as unknown as NativeDatabase; + restore = installMobileLocalKnowledgeTreeBridge(repository,{deviceOnly:false},db); + return {a,b,moves,overrides,remoteMove,remoteReorder,remoteList}; +} +it("routes personal folder move, batch move and reorder through the durable transaction without HTTP", async () => { + const f=setup(); + expect(await knowledgeTreeApi.move(f.a.id,{parentId:f.b.id,sortOrder:7})).toMatchObject({parentId:f.b.id,sortOrder:7}); + await knowledgeTreeApi.batchMove([f.a.id],{parentId:f.b.id}); + await knowledgeTreeApi.reorder([{id:f.a.id,sortOrder:7}]); + expect(f.moves.mock.calls).toEqual([[[{id:f.a.id,parentId:f.b.id,sortOrder:7}]],[[{id:f.a.id,parentId:f.b.id}]],[[{id:f.a.id,sortOrder:7}]]]); + expect(f.remoteMove).not.toHaveBeenCalled(); expect(f.remoteReorder).not.toHaveBeenCalled(); +}); +it("shows only pending folder intentions over a complete cached owner tree after HTTP becomes unavailable", async () => { + const f=setup(); await knowledgeTreeApi.listForWorkspace("personal"); + f.overrides.mockResolvedValue([{id:f.a.id,parentId:f.b.id,sortOrder:7}]); + f.remoteList.mockRejectedValue(new TypeError("Failed to fetch")); + const tree=await knowledgeTreeApi.listForWorkspace("personal"); + expect(tree.nodes.find((n)=>n.id===f.a.id)).toMatchObject({parentId:f.b.id,sortOrder:7}); + expect(tree.nodes.find((n)=>n.id===f.b.id)?.childCount).toBe(1); +}); +it("retains current password metadata instead of moving protected descendants into an unprotected navigation path", async () => { + const f=setup(); + f.remoteList.mockResolvedValue({nodes:[{...f.a,parentId:f.b.id},{...f.b,isPasswordProtected:1}]}); + f.overrides.mockResolvedValue([{id:f.a.id,parentId:null,sortOrder:7}]); + expect((await knowledgeTreeApi.listForWorkspace("personal")).nodes.find((n)=>n.id===f.a.id)?.parentId).toBe(f.b.id); +}); +it("falls back for unavailable authorization but propagates a failed local commit without issuing another write", async () => { + const f=setup(); + f.moves.mockRejectedValueOnce(Object.assign(new Error("Online authorization required"),{code:"NATIVE_TREE_WRITE_UNAVAILABLE"})); + await knowledgeTreeApi.move(f.a.id,{parentId:null}); expect(f.remoteMove).toHaveBeenCalledOnce(); + f.moves.mockRejectedValueOnce(new Error("Outbox storage failed")); + await expect(knowledgeTreeApi.move(f.a.id,{parentId:null})).rejects.toThrow("Outbox storage failed"); + expect(f.remoteMove).toHaveBeenCalledOnce(); + localStorage.setItem("nowen-current-workspace","workspace-a"); + await knowledgeTreeApi.move(f.a.id,{parentId:null}); expect(f.moves).toHaveBeenCalledTimes(2); +}); diff --git a/frontend/src/lib/__tests__/mobileSignedInTreeCreate.test.ts b/frontend/src/lib/__tests__/mobileSignedInTreeCreate.test.ts index 6da90f68f..ba1f330a0 100644 --- a/frontend/src/lib/__tests__/mobileSignedInTreeCreate.test.ts +++ b/frontend/src/lib/__tests__/mobileSignedInTreeCreate.test.ts @@ -22,6 +22,7 @@ function setup(parent?: KnowledgeTreeNode) { : metadata.has(String(values[0])) ? [{ value: metadata.get(String(values[0])) }] : []), } as unknown as NativeDatabase; const repository = { + listPersonalTreeOverrides: vi.fn(async () => []), listLocalTreeDrafts: vi.fn(async () => drafts), createPersonalTreeNote: vi.fn(async (input: Note, notebookId: string | null) => { drafts.push({ ...input, userId: "user", workspaceId: null, notebookId: notebookId || "hidden-root", diff --git a/frontend/src/lib/__tests__/nativeTreeSchemaMigration.test.ts b/frontend/src/lib/__tests__/nativeTreeSchemaMigration.test.ts new file mode 100644 index 000000000..8f1f796ed --- /dev/null +++ b/frontend/src/lib/__tests__/nativeTreeSchemaMigration.test.ts @@ -0,0 +1,68 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { createRequire } from "node:module"; +import type { DatabaseSync } from "node:sqlite"; +import { readFileSync } from "node:fs"; +import { URL as NodeURL } from "node:url"; +import { openNativeDatabase } from "../nativeDatabase"; + +const fixture = vi.hoisted(() => ({ raw: undefined as unknown as DatabaseSync, fail: false })); +const { DatabaseSync: SQLiteDatabase } = createRequire(import.meta.url)("node:sqlite") as typeof import("node:sqlite"); +vi.mock("@capacitor/core", () => ({ Capacitor: { isNativePlatform: () => true } })); +vi.mock("@capacitor-community/sqlite", () => ({ + CapacitorSQLite: {}, SQLiteConnection: class { + async checkConnectionsConsistency() { return {result:true}; } + async isConnection() { return {result:false}; } + async closeConnection() {} + async createConnection() { return { + isDBOpen: async () => ({result:true}), close: async () => {}, + execute: async (sql:string) => { + if (fixture.fail && sql === "ALTER TABLE sync_conflicts_v2 RENAME TO sync_conflicts") throw new Error("Migration interruption"); + fixture.raw.exec(sql); return {}; + }, + run: async (sql:string, values: never[] = []) => ({changes:fixture.raw.prepare(sql).run(...values)}), + query: async (sql:string, values: never[] = []) => ({values:fixture.raw.prepare(sql).all(...values)}), + }; } + }, +})); + +beforeEach(() => { + Reflect.deleteProperty(globalThis,"__nowenNoteNativeDatabaseState"); + fixture.fail = false; fixture.raw = new SQLiteDatabase(":memory:"); + fixture.raw.exec("PRAGMA foreign_keys=ON"); + const source = readFileSync(new NodeURL("../nativeDatabase.ts",import.meta.url),"utf8"); + const scope = source.match(/const SCOPE_CHECK = `([\s\S]*?)`;/)![1]; + const types = source.match(/const ENTITY_TYPE_CHECK = `([\s\S]*?)`;/)![1].replace(", 'knowledge_tree_node'",""); + for (const [,sql] of source.matchAll(/`(CREATE TABLE IF NOT EXISTS [\s\S]*?)`/g)) { + fixture.raw.exec(sql.replaceAll("${SCOPE_CHECK}",scope).replaceAll("${ENTITY_TYPE_CHECK}",types)); + } + fixture.raw.exec(`PRAGMA user_version=6; + INSERT INTO sync_profiles(id,name,serverUrl,remoteUserId,createdAt,updatedAt) VALUES ('p','fixture','http://localhost','u','now','now'); + INSERT INTO sync_devices(profileId,deviceId,platform,createdAt) VALUES ('p','d','android','now'); + INSERT INTO sync_outbox(id,mutationId,profileId,deviceId,scopeKey,entityType,entityId,operation,baseVersion,payload,status,retryCount,lastAttemptAt,lastError,createdAt) + VALUES ('o','stable-mutation','p','d','personal','note','n','upsert',17,'{"content":"fixture"}','inflight',4,'attempt','NETWORK_UNAVAILABLE','created'); + INSERT INTO sync_conflicts(id,profileId,scopeKey,entityType,entityId,localVersion,remoteVersion,basePayload,localPayload,remotePayload,status,createdAt,resolvedAt) + VALUES ('c','p','personal','note','n',18,19,'{}','{"side":"local"}','{"side":"remote"}','resolved','created','resolved');`); +}); +afterEach(() => { fixture.raw.close(); Reflect.deleteProperty(globalThis,"__nowenNoteNativeDatabaseState"); }); + +it("migrates schema 6 queues without losing mutation identity, retries, versions or resolved history", async () => { + const before = fixture.raw.prepare("SELECT * FROM sync_outbox").all(), history = fixture.raw.prepare("SELECT * FROM sync_conflicts").all(); + const db = await openNativeDatabase("tree-migration-fixture"); + expect(fixture.raw.prepare("SELECT * FROM sync_outbox").all()).toEqual(before); + expect(fixture.raw.prepare("SELECT * FROM sync_conflicts").all()).toEqual(history); + expect(fixture.raw.prepare("PRAGMA user_version").get()).toEqual({user_version:7}); + await db.run(`INSERT INTO sync_outbox(id,mutationId,profileId,deviceId,scopeKey,entityType,entityId,operation,createdAt) + VALUES ('tree','tree-mutation','p','d','personal','knowledge_tree_node','note:n','upsert','now')`); + expect(fixture.raw.prepare("PRAGMA foreign_key_check").all()).toEqual([]); + await db.close(); +}); + +it("rolls back both queue rebuilds and schema version when the second rebuild fails", async () => { + const before = fixture.raw.prepare("SELECT * FROM sync_outbox").all(), history = fixture.raw.prepare("SELECT * FROM sync_conflicts").all(); + fixture.fail = true; + await expect(openNativeDatabase("tree-migration-fixture")).rejects.toThrow("Migration interruption"); + expect(fixture.raw.prepare("PRAGMA user_version").get()).toEqual({user_version:6}); + expect(fixture.raw.prepare("SELECT * FROM sync_outbox").all()).toEqual(before); + expect(fixture.raw.prepare("SELECT * FROM sync_conflicts").all()).toEqual(history); + expect(fixture.raw.prepare("SELECT sql FROM sqlite_master WHERE name='sync_outbox'").get()?.sql).not.toContain("knowledge_tree_node"); +}); diff --git a/frontend/src/lib/encryptedNotes/__tests__/nativeDatabaseInitialization.test.ts b/frontend/src/lib/encryptedNotes/__tests__/nativeDatabaseInitialization.test.ts index ce604ae50..830916632 100644 --- a/frontend/src/lib/encryptedNotes/__tests__/nativeDatabaseInitialization.test.ts +++ b/frontend/src/lib/encryptedNotes/__tests__/nativeDatabaseInitialization.test.ts @@ -20,13 +20,13 @@ vi.mock("@capacitor-community/sqlite", () => ({ beforeEach(() => { mocks.version = 0; mocks.execute.mockReset().mockResolvedValue({}); mocks.run.mockReset().mockResolvedValue({}); mocks.close.mockReset().mockResolvedValue(undefined); Reflect.deleteProperty(globalThis, "__nowenNoteNativeDatabaseState"); }); afterEach(() => { Reflect.deleteProperty(globalThis, "__nowenNoteNativeDatabaseState"); }); -it.each([0, 4])("initializing schema %s installs all persisted guards before acknowledging version 6", async (version) => { +it.each([0, 4])("initializing schema %s installs all persisted guards before acknowledging version 7", async (version) => { mocks.version = version; const db = await openNativeDatabase("encryption-test-account"); const statements = mocks.execute.mock.calls.map(([sql]) => sql); for (const guard of NATIVE_ENCRYPTED_NOTE_GUARDS) { expect(statements).toContain(guard); - expect(statements.indexOf(guard)).toBeLessThan(statements.indexOf("PRAGMA user_version = 6")); + expect(statements.indexOf(guard)).toBeLessThan(statements.indexOf("PRAGMA user_version = 7")); } expect(statements.at(-1)).toBe("COMMIT"); await db.close(); @@ -37,17 +37,19 @@ it("a failed guard install rolls back without acknowledging a migrated schema", await expect(openNativeDatabase("encryption-test-account")).rejects.toThrow("Failed DDL"); const statements = mocks.execute.mock.calls.map(([sql]) => sql); expect(statements.at(-1)).toBe("ROLLBACK"); - expect(statements).not.toContain("PRAGMA user_version = 6"); expect(statements).not.toContain("COMMIT"); + expect(statements).not.toContain("PRAGMA user_version = 7"); expect(statements).not.toContain("COMMIT"); }); -it("upgrades schema 5 with structural metadata before acknowledging version 6", async () => { +it("upgrades schema 5 with structural metadata and queues before acknowledging version 7", async () => { mocks.version = 5; const db = await openNativeDatabase("tree-upgrade-account"); const statements = mocks.execute.mock.calls.map(([sql]) => sql as string); const table = statements.findIndex((sql) => sql.startsWith("CREATE TABLE IF NOT EXISTS native_knowledge_tree_nodes")); expect(table).toBeGreaterThan(0); - expect(table).toBeLessThan(statements.indexOf("PRAGMA user_version = 6")); - expect(statements.some((sql) => /DROP TABLE|DELETE FROM/.test(sql))).toBe(false); + expect(table).toBeLessThan(statements.indexOf("PRAGMA user_version = 7")); + expect(statements.some((sql) => /DELETE FROM/.test(sql))).toBe(false); + expect(statements.some((sql) => sql.includes("FROM sync_outbox"))).toBe(true); + expect(statements.some((sql) => sql.includes("FROM sync_conflicts"))).toBe(true); await db.close(); }); @@ -60,7 +62,7 @@ it("rolls back a failed schema 5 tree migration without advancing the version", await expect(openNativeDatabase("tree-failed-upgrade-account")).rejects.toThrow("Tree DDL failure"); const statements = mocks.execute.mock.calls.map(([sql]) => sql); expect(statements.at(-1)).toBe("ROLLBACK"); - expect(statements).not.toContain("PRAGMA user_version = 6"); + expect(statements).not.toContain("PRAGMA user_version = 7"); }); async function verifyConcurrentRead(rollback: boolean) { diff --git a/frontend/src/lib/mobileLocalKnowledgeTreeBridge.ts b/frontend/src/lib/mobileLocalKnowledgeTreeBridge.ts index 5f4e7fb20..6b8871ebf 100644 --- a/frontend/src/lib/mobileLocalKnowledgeTreeBridge.ts +++ b/frontend/src/lib/mobileLocalKnowledgeTreeBridge.ts @@ -164,7 +164,7 @@ function localOnlyUnsupported(message: string): Error & { code?: string } { * * - 纯设备本地模式:列表从 Native Repository 投影,并接管 CRUD / batch / ACL。 * - 已登录 Local-first:个人根级/普通文件夹的 MD/富文本可本地创建;本机创建记录 - * 补充服务端树或离线快照。结构移动、共享、密码等高级能力仍由服务端处理。 + * 补充服务端树或离线快照;已订阅的普通个人节点支持事务移动/排序。 */ export function installMobileLocalKnowledgeTreeBridge( repository: NativeLocalRepository, @@ -203,8 +203,35 @@ export function installMobileLocalKnowledgeTreeBridge( if (!deviceOnly) { const withLocalDrafts = async (result: KnowledgeTreeResponse, scope: string, online: boolean): Promise => { if (!db || scope !== "personal") return result; + const overrides = await repository.listPersonalTreeOverrides(); + if (overrides.length) { + const safeChain = (node: KnowledgeTreeNode, nodes: KnowledgeTreeNode[]) => { + const byId = new Map(nodes.map((item) => [item.id,item])); + const visited = new Set(); + let current: KnowledgeTreeNode | undefined = node; + while (current) { + if (visited.has(current.id) || current.isDeleted || current.access?.source !== "owner" + || !current.access.capabilities.canView || (current.resourceType === "notebook" && current.isPasswordProtected !== 0)) return false; + visited.add(current.id); + if (!current.parentId) return true; + current = byId.get(current.parentId); + } + return false; + }; + const nodes = result.nodes.map((node) => { + const override = overrides.find((row) => row.id === node.id); + return override ? { ...node,parentId:override.parentId as string | null,sortOrder:override.sortOrder as number } : node; + }); + result = { ...result,nodes:nodes.map((node,index) => + safeChain(result.nodes[index],result.nodes) && safeChain(node,nodes) ? node : result.nodes[index]) }; + } const drafts = await repository.listLocalTreeDrafts(); - if (!drafts.length) return result; + if (!drafts.length) { + if (!overrides.length) return result; + const counts = new Map(); + for (const node of result.nodes) if (node.parentId) counts.set(node.parentId,(counts.get(node.parentId)||0)+1); + return {...result,nodes:applyKnowledgeTreeSort(result.nodes.map((node) => ({...node,childCount:counts.get(node.id)||0})))}; + } const nodes = [...result.nodes]; for (const draft of drafts) { const key = `localTreeDraft:personal:${draft.id}`; @@ -318,6 +345,21 @@ export function installMobileLocalKnowledgeTreeBridge( }; target.create = (input) => createPersonal(getCurrentWorkspace(), input); target.createForWorkspace = createPersonal; + const localMoves = async (items: Array<{id:string;parentId?:string|null;sortOrder?:number}>) => { + if (!db || getCurrentWorkspace() !== "personal") return null; + try { return await repository.movePersonalTreeNodes(items); } + catch (error) { + if ((error as {code?:string})?.code === "NATIVE_TREE_WRITE_UNAVAILABLE") return null; + throw error; + } + }; + target.move = async (id,input) => (await localMoves([{id,...input}]))?.[0] || originals.move(id,input); + target.batchMove = async (nodeIds,input) => { + const nodes = await localMoves(nodeIds.map((id) => ({id,...input}))); + return nodes ? {success:true,nodeIds,nodes} : originals.batchMove(nodeIds,input); + }; + target.reorder = async (items) => await localMoves(items) + ? {success:true,updated:items.length} : originals.reorder(items); return () => { Object.assign(target, originals); }; } diff --git a/frontend/src/lib/mobileSyncEngine.ts b/frontend/src/lib/mobileSyncEngine.ts index d04e10db1..c45a2f400 100644 --- a/frontend/src/lib/mobileSyncEngine.ts +++ b/frontend/src/lib/mobileSyncEngine.ts @@ -8,7 +8,8 @@ import { forgetUnsentLocalNotes } from "./nativeLocalNoteOrigin"; import { notifyMobileSyncStatusChanged, type MobileSyncAppliedDetail } from "./mobileSyncStatus"; import { nativeReceiptKey } from "./mobileNoteSyncReceipt"; import { validateEncryptedNoteWrite } from "./encryptedNotes/noteDocument"; -import { applyNativePersonalTree, NATIVE_TREE_ENTITY_SET, NATIVE_TREE_PENDING_SUBSCRIPTION, nativeTreeSubscriptionKey, type NativeTreeEntry } from "./nativeKnowledgeTreeSync"; +import { applyNativePersonalTree, assertNativePersonalTreeEntry, NATIVE_TREE_ENTITY_SET, NATIVE_TREE_PENDING_SUBSCRIPTION, nativeTreeSubscriptionKey, type NativeTreeEntry } from "./nativeKnowledgeTreeSync"; +import { nativeTreeMutationPayload, readNativePersonalTreeNode, sameNativeTreePosition, syncNativeNotebookMoveParents, writeNativePersonalTreeMoves } from "./nativePersonalTreeWrites"; type ScopeStatus = "active" | "replan_required" | "access_revoked"; type EntityType = "notebook" | "note" | "tag" | "note_tag" | "favorite" | "attachment" @@ -195,6 +196,7 @@ export class MobileSyncEngine { ))[0]?.value === NATIVE_TREE_ENTITY_SET; if (!state || state.lastSequence === 0 || state.accessStatus === "replan_required" || !treeBinding) { await this.bootstrap(scope); + if (this.hasTreeSubscription(scope)) await this.push(scope); } if (this.stopped) return; await this.pull(scope); @@ -250,6 +252,34 @@ export class MobileSyncEngine { localVersion:number|null;remoteVersion:number|null;localPayload:string|null;remotePayload:string|null;status:string; }>("SELECT * FROM sync_conflicts WHERE id=? AND profileId=?",[conflictId,this.options.profileId]))[0]; if(!row||row.status==="resolved")return; + if (row.entityType === "knowledge_tree_node") { + if (row.scopeKey !== "personal" || resolution === "manual") throw new Error("个人知识树冲突请选择本机或服务器位置"); + await this.options.db.transaction(async (tx) => { + const remote = row.remotePayload ? JSON.parse(row.remotePayload) as Record : null; + const local = await readNativePersonalTreeNode(tx,row.entityId); + if (!remote || !local || (remote.__delete && resolution !== "keep-remote")) throw new Error("知识树业务资源缺失,无法安全采用该位置"); + assertNativePersonalTreeEntry(row.entityId,remote,this.options.userId); + if (resolution === "keep-local") { + // Recheck cached owner/password ancestry, including the latest move + // made while the conflict was waiting for the user's choice. + await writeNativePersonalTreeMoves(tx,[{id:row.entityId}],{profileId:this.options.profileId,deviceId:this.options.deviceId,userId:this.options.userId}); + } + const payload = resolution === "keep-remote" ? remote : local; + await applyNativePersonalTree(tx,[{entityId:row.entityId,payload}],this.options.userId,false); + await syncNativeNotebookMoveParents(tx,[row.entityId]); + await tx.run(`DELETE FROM sync_outbox WHERE profileId=? AND scopeKey='personal' + AND entityType='knowledge_tree_node' AND entityId=?`, [this.options.profileId,row.entityId]); + if (resolution === "keep-local") await tx.run(`INSERT INTO sync_outbox + (id,mutationId,profileId,deviceId,scopeKey,entityType,entityId,operation,payload,status,retryCount,createdAt) + VALUES (?,?,?,?,'personal','knowledge_tree_node',?,'upsert',?,'pending',0,?)`, + [newLocalId(),newLocalId(),this.options.profileId,this.options.deviceId,row.entityId,JSON.stringify(nativeTreeMutationPayload(local,remote)),now()]); + await tx.run("UPDATE sync_conflicts SET status='resolved',resolvedAt=? WHERE id=?",[now(),conflictId]); + }); + notifyMobileSyncStatusChanged(); + this.options.onApplied?.({scopeKey:"personal",noteIds:[],deletedNoteIds:[]}); + this.requestSync(0); + return; + } if(!isCoreEntityType(row.entityType))throw new Error("该冲突类型暂不支持在移动端合并"); const entityType = row.entityType; const parse=(value:string|null)=>value?JSON.parse(value) as Record:null; @@ -474,7 +504,20 @@ export class MobileSyncEngine { } while (cursor); await this.restoreNotebookParents(scope,notebookParents); if (this.hasTreeSubscription(scope)) await this.options.db.transaction(async (tx) => { + const protectedTree = (await tx.query(`SELECT entityId FROM sync_outbox WHERE profileId=? AND scopeKey='personal' + AND entityType='knowledge_tree_node' UNION SELECT entityId FROM sync_conflicts WHERE profileId=? AND scopeKey='personal' + AND entityType='knowledge_tree_node' AND status='unresolved' LIMIT 1`, [this.options.profileId,this.options.profileId])).length; + const local = protectedTree ? await tx.query>(`SELECT id,userId,workspaceId,parentId,nodeType,resourceType, + resourceId,sortOrder,isDeleted,deletedAt,createdAt,updatedAt FROM native_knowledge_tree_nodes WHERE scopeKey='personal'`) : []; + // Validate the complete remote graph before reconciling pending local + // intentions. All three Apply passes belong to this same transaction. await applyNativePersonalTree(tx,tree,this.options.userId,true); + if (protectedTree) { + await applyNativePersonalTree(tx,local.map((payload) => ({entityId:String(payload.id),payload})),this.options.userId,true); + const seen = new Set(tree.map((entry) => entry.entityId)); + await this.applyTreeEntries(tx,[...tree,...local.filter((row) => !seen.has(String(row.id))) + .map((row) => ({entityId:String(row.id),payload:{__delete:true}}))]); + } // Persist the negotiated graph and subscription intent together. A crash // after the server's ACK must not allow a silent legacy downgrade. await tx.run(`INSERT INTO native_runtime_meta (key,value,updatedAt) VALUES (?,?,?) @@ -590,7 +633,7 @@ export class MobileSyncEngine { private async push(scope: ScopeDescriptor): Promise { const rows = await this.options.db.query<{ - mutationId:string;entityType:EntityType;entityId:string;operation:"upsert"|"delete"; + mutationId:string;entityType:RemoteEntityType;entityId:string;operation:"upsert"|"delete"; baseVersion:number|null;payload:string|null; }>(`SELECT mutationId,entityType,entityId,operation,baseVersion,payload FROM sync_outbox WHERE profileId=? AND scopeKey=? AND status IN ('pending','failed') @@ -601,9 +644,18 @@ export class MobileSyncEngine { [this.options.profileId,scope.scopeKey]); if (!rows.length || !scope.canWrite && scope.workspaceId) return; for(const row of rows){ - if(!isCoreEntityType(row.entityType)||row.operation!=="upsert"&&row.operation!=="delete") + if((!isCoreEntityType(row.entityType) && !(this.hasTreeSubscription(scope) && row.entityType === "knowledge_tree_node"))||row.operation!=="upsert"&&row.operation!=="delete") throw syncFailure("SERVER_ERROR","本地存在当前客户端不支持的同步实体或操作"); } + // The server binds the complete subscription only after Snapshot ACK. + // Business creates can replay first; structural intentions wait for it. + const binding = this.hasTreeSubscription(scope) && (await this.options.db.query<{value:string}>( + "SELECT value FROM native_runtime_meta WHERE key=?", [nativeTreeSubscriptionKey(this.options.profileId)], + ))[0]?.value === NATIVE_TREE_ENTITY_SET; + if (!binding) { + for (let i=rows.length-1;i>=0;i--) if (rows[i].entityType === "knowledge_tree_node") rows.splice(i,1); + } + if (!rows.length) return; const mutations = rows.map((row) => ({ ...row, baseVersion:row.baseVersion??undefined,payload:row.payload?JSON.parse(row.payload):undefined })); // Acknowledgements rebase all unsent descendants to the confirmed version. @@ -668,6 +720,15 @@ export class MobileSyncEngine { } } else if (result.code === "VERSION_CONFLICT" && source) { const local = await this.readEntity(tx, scope.scopeKey, source.entityType, source.entityId); + const existing = (await tx.query<{id:string}>(`SELECT id FROM sync_conflicts WHERE profileId=? AND scopeKey=? + AND entityType=? AND entityId=? AND status='unresolved' LIMIT 1`, + [this.options.profileId,scope.scopeKey,source.entityType,source.entityId]))[0]; + if (existing) { + await tx.run("UPDATE sync_conflicts SET localPayload=?,remotePayload=? WHERE id=?", + [local ? JSON.stringify(local) : source.payload,JSON.stringify(result.serverPayload||{version:result.serverVersion}),existing.id]); + await tx.run("DELETE FROM sync_outbox WHERE mutationId=?",[result.mutationId]); + continue; + } await tx.run(`INSERT INTO sync_conflicts ( id,profileId,scopeKey,entityType,entityId,localVersion,remoteVersion, localPayload,remotePayload,status,createdAt @@ -792,7 +853,7 @@ export class MobileSyncEngine { } const tree = entries.filter((entry) => entry.entityType === "knowledge_tree_node"); if (tree.length) { - await applyNativePersonalTree(tx,tree,this.options.userId,false); + await this.applyTreeEntries(tx,tree); applied.push(...tree); } for(const [id,parentId] of notebookParents){ @@ -807,7 +868,36 @@ export class MobileSyncEngine { }); } - private async readEntity(db:NativeDatabase,scopeKey:string,type:EntityType,id:string):Promise|null>{ + private async applyTreeEntries(tx: NativeDatabase, entries: NativeTreeEntry[]): Promise { + const applicable: NativeTreeEntry[] = []; + for (const entry of entries) { + assertNativePersonalTreeEntry(entry.entityId,entry.payload,this.options.userId); + const conflict = (await tx.query<{id:string}>(`SELECT id FROM sync_conflicts WHERE profileId=? AND scopeKey='personal' + AND entityType='knowledge_tree_node' AND entityId=? AND status='unresolved' LIMIT 1`, [this.options.profileId,entry.entityId]))[0]; + if (conflict) { + await tx.run("UPDATE sync_conflicts SET remotePayload=? WHERE id=?",[JSON.stringify(entry.payload),conflict.id]); + continue; + } + const queuedRows = await tx.query<{payload:string}>(`SELECT payload FROM sync_outbox WHERE profileId=? AND scopeKey='personal' + AND entityType='knowledge_tree_node' AND entityId=? ORDER BY createdAt,rowid`, [this.options.profileId,entry.entityId]); + const pending = queuedRows[0]; + if (pending) { + const local = await readNativePersonalTreeNode(tx,entry.entityId), queued = JSON.parse(pending.payload) as Record; + const base = {parentId:queued.baseParentId,sortOrder:queued.baseSortOrder,isDeleted:queued.baseIsDeleted}; + if (!entry.payload.__delete && (sameNativeTreePosition(base,entry.payload) || (local && sameNativeTreePosition(local,entry.payload)) + || queuedRows.some((row) => sameNativeTreePosition(JSON.parse(row.payload) as Record,entry.payload)))) continue; + await tx.run(`INSERT INTO sync_conflicts (id,profileId,scopeKey,entityType,entityId,localPayload,remotePayload,status,createdAt) + VALUES (?,?,'personal','knowledge_tree_node',?,?,?,'unresolved',?)`, + [newLocalId(),this.options.profileId,entry.entityId,JSON.stringify(local),JSON.stringify(entry.payload),now()]); + continue; + } + applicable.push(entry); + } + await applyNativePersonalTree(tx,applicable,this.options.userId,false); + } + + private async readEntity(db:NativeDatabase,scopeKey:string,type:RemoteEntityType,id:string):Promise|null>{ + if (type === "knowledge_tree_node") return scopeKey === "personal" ? readNativePersonalTreeNode(db,id) : null; const table=type==="notebook"?"notebooks":type==="note"?"notes":type==="tag"?"tags":type==="attachment"?"attachments" :type==="task"?"tasks":type==="task_reminder"?"task_reminders":type==="diary"?"diaries":type==="mindmap"?"mindmaps":null; if(!table)return null; diff --git a/frontend/src/lib/nativeDatabase.ts b/frontend/src/lib/nativeDatabase.ts index 4aaed5012..0960cdbbf 100644 --- a/frontend/src/lib/nativeDatabase.ts +++ b/frontend/src/lib/nativeDatabase.ts @@ -33,12 +33,12 @@ type NativeDatabaseGlobal = typeof globalThis & { __nowenNoteNativeDatabaseState?: NativeDatabaseGlobalState; }; -const SCHEMA_VERSION = 6; +const SCHEMA_VERSION = 7; const DATABASE_PREFIX = "nowen_local_"; const ENTITY_TYPE_CHECK = ` 'notebook', 'note', 'tag', 'note_tag', 'favorite', 'attachment', - 'task', 'task_reminder', 'diary', 'mindmap' + 'task', 'task_reminder', 'diary', 'mindmap', 'knowledge_tree_node' `; const SCOPE_CHECK = ` @@ -762,6 +762,12 @@ class NativeDatabaseImpl implements NativeDatabase { } if (version < 6) { await executeSchemaStatements(this.raw, KNOWLEDGE_TREE_SCHEMA_STATEMENTS); + } + if (version < 7) { + // Preserve mutation IDs, payloads, retries and conflict history while + // widening the entity CHECK. Both rebuilds share the migration TX. + await executeSchemaStatements(this.raw, SYNC_OUTBOX_V2_STATEMENTS); + await executeSchemaStatements(this.raw, SYNC_CONFLICTS_V2_STATEMENTS); await this.raw.execute(`PRAGMA user_version = ${SCHEMA_VERSION}`, false); } }); diff --git a/frontend/src/lib/nativeKnowledgeTreeSync.ts b/frontend/src/lib/nativeKnowledgeTreeSync.ts index 324dd25e8..d29a2b2a0 100644 --- a/frontend/src/lib/nativeKnowledgeTreeSync.ts +++ b/frontend/src/lib/nativeKnowledgeTreeSync.ts @@ -22,6 +22,27 @@ const nodeTypes: Record = { notebook: ["folder"], note: ["note", "markdown", "word"], mindmap: ["mindmap"], file: ["file"], }; +export function assertNativePersonalTreeEntry(entityId: string, p: Record, userId: string): void { + const type = p.resourceType; + if (p.__delete === true) return; + if (typeof type !== "string" || !Object.hasOwn(nodeTypes,type) + || typeof p.resourceId !== "string" || !p.resourceId + || entityId !== `${type}:${p.resourceId}` || p.id !== entityId + || p.userId !== userId || p.workspaceId !== null + || (p.scopeKey !== undefined && p.scopeKey !== `personal:${userId}`) + || typeof p.nodeType !== "string" || !nodeTypes[type].includes(p.nodeType) + || (p.parentId !== null && (typeof p.parentId !== "string" || !p.parentId)) + || p.parentId === entityId + || !Number.isSafeInteger(p.sortOrder) || (p.sortOrder as number) < 0 + || (p.isDeleted !== 0 && p.isDeleted !== 1) + || (p.deletedAt !== null && typeof p.deletedAt !== "string") + || typeof p.createdAt !== "string" || !p.createdAt + || typeof p.updatedAt !== "string" || !p.updatedAt + || entityId.startsWith("notebook:__nowen_root_documents__:")) { + invalid("知识树资源身份、作用域或结构字段无效"); + } +} + /** Caller owns the Apply transaction. Never writes business rows, Outbox or access proofs. */ export async function applyNativePersonalTree( tx: NativeDatabase, entries: NativeTreeEntry[], userId: string, snapshot: boolean, @@ -36,22 +57,8 @@ export async function applyNativePersonalTree( await tx.run("DELETE FROM native_knowledge_tree_nodes WHERE scopeKey='personal' AND id=?", [entityId]); continue; } - const type = p.resourceType; - if (typeof type !== "string" || !Object.hasOwn(nodeTypes,type) - || typeof p.resourceId !== "string" || !p.resourceId - || entityId !== `${type}:${p.resourceId}` || p.id !== entityId - || p.userId !== userId || p.workspaceId !== null - || (p.scopeKey !== undefined && p.scopeKey !== `personal:${userId}`) - || typeof p.nodeType !== "string" || !nodeTypes[type].includes(p.nodeType) - || (p.parentId !== null && (typeof p.parentId !== "string" || !p.parentId)) - || !Number.isSafeInteger(p.sortOrder) || (p.sortOrder as number) < 0 - || (p.isDeleted !== 0 && p.isDeleted !== 1) - || (p.deletedAt !== null && typeof p.deletedAt !== "string") - || typeof p.createdAt !== "string" || !p.createdAt - || typeof p.updatedAt !== "string" || !p.updatedAt - || entityId.startsWith("notebook:__nowen_root_documents__:")) { - invalid("知识树资源身份、作用域或结构字段无效"); - } + assertNativePersonalTreeEntry(entityId,p,userId); + const type = p.resourceType as string; const tables: Record = { notebook: "notebooks", note: "notes", mindmap: "mindmaps", file: "attachments" }; const table = tables[type]; const resource = await tx.query(`SELECT id FROM ${table} WHERE scopeKey='personal' AND id=?`, [p.resourceId]); diff --git a/frontend/src/lib/nativeLocalRepository.ts b/frontend/src/lib/nativeLocalRepository.ts index 8201dd93c..7ea15982a 100644 --- a/frontend/src/lib/nativeLocalRepository.ts +++ b/frontend/src/lib/nativeLocalRepository.ts @@ -13,6 +13,7 @@ import { unsentLocalNoteKey } from "./nativeLocalNoteOrigin"; import { notifyMobileSyncStatusChanged } from "./mobileSyncStatus"; import { isEncryptedNoteFormat, validateEncryptedNoteWrite } from "./encryptedNotes/noteDocument"; import { ROOT_DOCUMENT_NOTEBOOK_PREFIX } from "./rootDocumentCreatePolicy"; +import { listNativePersonalTreeOverrides, writeNativePersonalTreeMoves, type NativeTreeMove } from "./nativePersonalTreeWrites"; type EntityType = "notebook" | "note" | "tag" | "note_tag" | "favorite" | "attachment"; @@ -289,6 +290,22 @@ export class NativeLocalRepository implements LocalRepository { this.requestSyncCallback?.(); } + async movePersonalTreeNodes(items: NativeTreeMove[]) { + const nodes = await this.db.transaction(async (tx) => { + const sync = await this.syncContext(tx); + if (!sync) throw Object.assign(new Error("个人目录同步订阅尚未确认"), { code: "NATIVE_TREE_WRITE_UNAVAILABLE" }); + return writeNativePersonalTreeMoves(tx,items,{ ...sync,userId:this.userId }); + }); + this.requestSyncCallback?.(); + notifyMobileSyncStatusChanged(); + return nodes; + } + + async listPersonalTreeOverrides() { + const sync = await this.syncContext(this.db); + return sync ? listNativePersonalTreeOverrides(this.db,sync.profileId) : []; + } + async exportWorkspaceScope(scopeKey:string):Promise> { return { format:"nowen-native-workspace-recovery-v1",scopeKey,exportedAt:now(), diff --git a/frontend/src/lib/nativePersonalTreeWrites.ts b/frontend/src/lib/nativePersonalTreeWrites.ts new file mode 100644 index 000000000..7f8da3fcf --- /dev/null +++ b/frontend/src/lib/nativePersonalTreeWrites.ts @@ -0,0 +1,119 @@ +import type { KnowledgeTreeNode } from "./knowledgeTreeApi"; +import type { NativeDatabase } from "./nativeDatabase"; +import { applyNativePersonalTree, NATIVE_TREE_ENTITY_SET, nativeTreeSubscriptionKey } from "./nativeKnowledgeTreeSync"; +import { newLocalId } from "./localRepository"; + +export interface NativeTreeMove { id: string; parentId?: string | null; sortOrder?: number } +export interface NativeTreeWriteContext { profileId: string; deviceId: string; userId: string } + +export async function readNativePersonalTreeNode(db: NativeDatabase, id: string): Promise | null> { + return (await db.query>(`SELECT id,userId,workspaceId,parentId,nodeType,resourceType, + resourceId,sortOrder,isDeleted,deletedAt,createdAt,updatedAt FROM native_knowledge_tree_nodes + WHERE scopeKey='personal' AND id=?`, [id]))[0] || null; +} + +export function sameNativeTreePosition(a: Record, b: Record): boolean { + return a.parentId === b.parentId && a.sortOrder === b.sortOrder && a.isDeleted === b.isDeleted; +} + +export function nativeTreeMutationPayload(desired: Record, base: Record): Record { + return { ...desired, baseParentId: base.parentId, baseSortOrder: base.sortOrder, baseIsDeleted: base.isDeleted }; +} + +/** Only explicit local intentions override navigation; synced rows carry no ACL proof. */ +export async function listNativePersonalTreeOverrides(db: NativeDatabase, profileId: string): Promise>> { + return db.query(`SELECT t.* FROM native_knowledge_tree_nodes t WHERE t.scopeKey='personal' AND ( + EXISTS (SELECT 1 FROM sync_outbox o WHERE o.profileId=? AND o.scopeKey='personal' + AND o.entityType='knowledge_tree_node' AND o.entityId=t.id) + OR EXISTS (SELECT 1 FROM sync_conflicts c WHERE c.profileId=? AND c.scopeKey='personal' + AND c.entityType='knowledge_tree_node' AND c.entityId=t.id AND c.status='unresolved'))`, [profileId,profileId]); +} + +function unavailable(): never { + throw Object.assign(new Error("个人目录订阅、权限或密码信息尚未确认,请使用在线操作"), { code: "NATIVE_TREE_WRITE_UNAVAILABLE" }); +} + +export async function syncNativeNotebookMoveParents(tx: NativeDatabase, ids: string[]): Promise { + for (const id of ids) { + const node = await readNativePersonalTreeNode(tx,id); + if (!node || node.resourceType !== "notebook") continue; + let parentId = node.parentId as string | null, container: string | null = null; + while (parentId) { + const parent = await readNativePersonalTreeNode(tx,parentId); + if (!parent) throw new Error("知识树父节点缺失"); + if (parent.resourceType === "notebook") { container = String(parent.resourceId); break; } + if (!parent.parentId && parent.resourceType === "note") { + container = (await tx.query<{notebookId:string}>("SELECT notebookId FROM notes WHERE scopeKey='personal' AND id=?",[parent.resourceId]))[0]?.notebookId || null; + } + parentId = parent.parentId as string | null; + } + await tx.run("UPDATE notebooks SET parentId=?,sortOrder=? WHERE scopeKey='personal' AND id=?",[container,node.sortOrder,node.resourceId]); + } +} + +/** Caller owns the transaction. Cached owner ACL and complete unprotected ancestry are required. */ +export async function writeNativePersonalTreeMoves( + tx: NativeDatabase, items: NativeTreeMove[], context: NativeTreeWriteContext, +): Promise { + const binding = (await tx.query<{value:string}>("SELECT value FROM native_runtime_meta WHERE key=?", [nativeTreeSubscriptionKey(context.profileId)]))[0]; + if (binding?.value !== NATIVE_TREE_ENTITY_SET) unavailable(); + const cached = (await tx.query<{value:string}>("SELECT value FROM native_runtime_meta WHERE key='knowledgeTreeSnapshot:v1:personal:0'"))[0]; + let nodes: KnowledgeTreeNode[]; + try { + const parsed: unknown = JSON.parse(cached?.value || "null"); + if (!Array.isArray(parsed)) unavailable(); + nodes = parsed; + } catch { unavailable(); } + const overrides = await listNativePersonalTreeOverrides(tx, context.profileId); + nodes = nodes.map((node) => { + const override = overrides.find((row) => row.id === node.id); + return override ? { ...node, parentId: override.parentId as string | null, sortOrder: override.sortOrder as number } : node; + }); + const byId = new Map(nodes.map((node) => [node.id,node])); + const assertAncestry = (id: string): KnowledgeTreeNode => { + const first = byId.get(id); let node = first; + const visited = new Set(); + while (node) { + if (visited.has(node.id) || node.userId !== context.userId || node.workspaceId !== null + || node.isDeleted || node.access?.source !== "owner" || !node.access.capabilities.canView + || (node.resourceType === "notebook" && node.isPasswordProtected !== 0)) unavailable(); + visited.add(node.id); + if (!node.parentId) return first!; + node = byId.get(node.parentId); + } + unavailable(); + }; + const entries = [], result: KnowledgeTreeNode[] = [], seen = new Set(); + for (const item of items) { + if (seen.has(item.id)) throw new Error("移动批次包含重复节点"); + seen.add(item.id); + const proof = assertAncestry(item.id); + // Document/mindmap/file writes also couple to body/attachment mutations. + // Keep their existing online path until those separate queues are covered. + if (proof.resourceType !== "notebook") unavailable(); + if (!proof.access.capabilities.canMove) unavailable(); + const current = await readNativePersonalTreeNode(tx,item.id); + if (!current || current.userId !== context.userId || current.isDeleted) unavailable(); + // A structure Pull newer than the ACL snapshot must not inherit an old + // unprotected path. Local pending overrides were incorporated above. + if (current.parentId !== proof.parentId) unavailable(); + const parentId = item.parentId === undefined ? current.parentId : item.parentId; + if (parentId) { + const parent = assertAncestry(String(parentId)); + const stored = await readNativePersonalTreeNode(tx,parent.id); + if (!stored || stored.isDeleted || stored.parentId !== parent.parentId + || (!parent.access.capabilities.canCreate && !parent.access.capabilities.canMove)) unavailable(); + } + const desired = { ...current, parentId, sortOrder: item.sortOrder ?? current.sortOrder, updatedAt: new Date().toISOString() }; + if (sameNativeTreePosition(current,desired)) { result.push(proof); continue; } + const payload = nativeTreeMutationPayload(desired,current); + entries.push({ entityId:item.id, payload:desired }); + await tx.run(`INSERT INTO sync_outbox (id,mutationId,profileId,deviceId,scopeKey,entityType,entityId, + operation,payload,status,retryCount,createdAt) VALUES (?,?,?,?,'personal','knowledge_tree_node',?,'upsert',?,'pending',0,?)`, + [newLocalId(),newLocalId(),context.profileId,context.deviceId,item.id,JSON.stringify(payload),desired.updatedAt]); + result.push({ ...proof, parentId: parentId as string | null, sortOrder: desired.sortOrder as number, updatedAt: desired.updatedAt }); + } + await applyNativePersonalTree(tx,entries,context.userId,false); + await syncNativeNotebookMoveParents(tx,items.map((item) => item.id)); + return result; +} diff --git a/frontend/tests/android-sync/main.ts b/frontend/tests/android-sync/main.ts index 78b7da524..9f9eaca24 100644 --- a/frontend/tests/android-sync/main.ts +++ b/frontend/tests/android-sync/main.ts @@ -66,6 +66,14 @@ async function initializeOnce(config: Config) { return (await db.query("SELECT id,parentId,sortOrder,isDeleted FROM native_knowledge_tree_nodes WHERE scopeKey='personal' AND id=?", [id]))[0] || null; }, async schema() { return db.query("PRAGMA user_version"); }, + async treeList() { return knowledgeTreeApi.listForWorkspace("personal"); }, + async treeMove(id: string, parentId: string | null, sortOrder: number) { return knowledgeTreeApi.move(id,{parentId,sortOrder}); }, + async pendingTree() { return db.query("SELECT mutationId,status FROM sync_outbox WHERE profileId=? AND entityType='knowledge_tree_node'",[profileId]); }, + async treeConflicts() { + const rows = await db.query<{id:string;localPayload:string;remotePayload:string}>("SELECT id,localPayload,remotePayload FROM sync_conflicts WHERE profileId=? AND entityType='knowledge_tree_node' AND status='unresolved'",[profileId]); + return rows.map((row) => ({id:row.id,local:JSON.parse(row.localPayload),remote:JSON.parse(row.remotePayload)})); + }, + async resolveTreeConflict(id: string) { await engine.resolveLocalConflict(id,"keep-local"); }, }; Object.assign(window, { acceptance, acceptanceRealtime: realtime }); document.getElementById("status")!.textContent = "Native SQLite ready. Test account only."; diff --git a/scripts/android-sync-acceptance.mjs b/scripts/android-sync-acceptance.mjs index cb6bf91a6..7c05b6bbe 100644 --- a/scripts/android-sync-acceptance.mjs +++ b/scripts/android-sync-acceptance.mjs @@ -104,24 +104,48 @@ try { const movedWithoutNotice = await fetch(`${base}/acceptance/tree-move/${encodeURIComponent(folder.id)}`, {method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({parentId:`note:${ids[0]}`,sortOrder:19})}); assert.ok(movedWithoutNotice.ok); await wait(async () => {const row=await native("structure",folder.id);return row?.parentId === `note:${ids[0]}` && row.sortOrder === 19;}, "poll did not compensate for a missing tree notification",40_000); + await native("treeList"); + await native("stop"); adb("reverse", "--remove", "tcp:47831"); + await native("treeMove",folder.id,`note:${ids[1]}`,29); + assert.equal((await native("pendingTree")).length,1); + const offlineTree = await native("treeList"); + assert.equal(offlineTree.nodes.find((node)=>node.id===folder.id)?.parentId,`note:${ids[1]}`); await browser.close(); adb("shell", "am", "force-stop", "com.nowen.note.syncacceptance"); adb("shell", "am", "start", "-n", "com.nowen.note.syncacceptance/com.nowen.note.MainActivity"); await attach(); const restoredStructure = await native("structure",folder.id); - assert.equal(restoredStructure.parentId,`note:${ids[0]}`); assert.equal(restoredStructure.sortOrder,19); - assert.equal((await native("schema"))[0].user_version,6); + assert.equal(restoredStructure.parentId,`note:${ids[1]}`); assert.equal(restoredStructure.sortOrder,29); + assert.equal((await native("pendingTree")).length,1); + assert.equal((await native("schema"))[0].user_version,7); + const serverTree = async () => (await fetch(`${base}/acceptance/tree/${encodeURIComponent(folder.id)}`)).json(); + adb("reverse","tcp:47831","tcp:47831"); + await native("start"); + await wait(async () => (await serverTree()).parentId===`note:${ids[1]}` && (await native("pendingTree")).length===0,"offline structure not automatically pushed"); + await native("treeList"); await native("stop"); + adb("reverse","--remove","tcp:47831"); + await native("treeMove",folder.id,`note:${ids[0]}`,30); + const concurrent = await fetch(`${base}/acceptance/tree-move/${encodeURIComponent(folder.id)}`, {method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({parentId:null,sortOrder:31})}); + assert.ok(concurrent.ok); + adb("reverse","tcp:47831","tcp:47831"); await native("start"); + await wait(async () => (await native("treeConflicts")).length===1,"concurrent move not retained in conflict center"); + const [conflict] = await native("treeConflicts"); + assert.equal(conflict.local.parentId,`note:${ids[0]}`); assert.equal(conflict.remote.parentId,null); + await native("resolveTreeConflict",conflict.id); + await wait(async () => (await serverTree()).parentId===`note:${ids[0]}` && (await native("pendingTree")).length===0,"keep-local structure did not converge"); + assert.equal((await native("treeConflicts")).length,0); const report = { device: serial, package: "com.nowen.note.syncacceptance", offlineImmediateRead: true, forceStopRecovery: true, knowledgeTreeRootCreate: true, readWriteThroughProductionApiBridge: true, - negotiatedPersonalTreePull:true,remoteFolderUnderDocument:true,remoteTreeMove:true,structureForceStopRecovery:true,nativeSchemaVersion:6, + negotiatedPersonalTreePull:true,remoteFolderUnderDocument:true,remoteTreeMove:true,structureForceStopRecovery:true,nativeSchemaVersion:7, + nativeOfflineTreeMoveAndSort:true,pendingTreeForceStopRecovery:true,automaticStructurePush:true,nativeConcurrentMoveKeepLocal:true, missingTreeNoticePoll:true,remoteFolderCreateMs,remoteTreeMoveMs, offlineNetworkIsolation: "adb reverse removed; HTTP server unreachable from Android", markdownAndRichText: true, missingNoticePoll: true, reconnect: true, httpPushToNativePull: stats(download), nativeCommitToServerAndAck: stats(upload), errors: await native("errors"), - boundary: "Loopback adb reverse; real Android Native SQLite, personal tree create/read/update and negotiated structural Pull. Remote folder creation and movement use the production knowledge tree routes. Native structural writes/conflict UI, full EditorPane, protected/shared ACL, attachment bytes and full production JWT middleware are not accepted here." }; + boundary: "Loopback adb reverse; actual Android Native SQLite, production API Bridge personal tree move/sort, Outbox restart recovery, automatic Push and concurrent move keep-local via the Native conflict service. Full ConflictCenter GUI, EditorPane, protected/shared ACL, attachment bytes and full production JWT middleware are not accepted here." }; fs.mkdirSync(new URL("../docs/test-results/", import.meta.url), { recursive: true }); - fs.writeFileSync(new URL("../docs/test-results/sync-v2-native-tree-pull-2026-10-11.json", import.meta.url), JSON.stringify(report, null, 2)); + fs.writeFileSync(new URL("../docs/test-results/sync-v2-native-tree-writes-2026-10-11.json", import.meta.url), JSON.stringify(report, null, 2)); console.log(JSON.stringify(report, null, 2)); } finally { if (page) await native("stop").catch(() => undefined); From 78a0e1adf54f91d056663af3b597042be0a7dd58 Mon Sep 17 00:00:00 2001 From: cropflre <767308865@qq.com> Date: Sun, 11 Oct 2026 19:56:49 +0800 Subject: [PATCH 08/13] fix(sync): hold native folder tombstones before cascading business deletion --- .../tests/sync-v2-mobile-multidevice.test.ts | 18 +++++++++ frontend/src/lib/mobileSyncEngine.ts | 37 ++++++++++++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/backend/tests/sync-v2-mobile-multidevice.test.ts b/backend/tests/sync-v2-mobile-multidevice.test.ts index 4789dad43..d38063de2 100644 --- a/backend/tests/sync-v2-mobile-multidevice.test.ts +++ b/backend/tests/sync-v2-mobile-multidevice.test.ts @@ -206,6 +206,24 @@ test("full tree rebuild preserves newer pending moves and recognizes an intermed assert.deepEqual(serverDb.prepare("SELECT parentId,sortOrder FROM knowledge_tree_nodes WHERE id=?").get(node),{parentId:right,sortOrder:2}); }); +test("a remote folder tombstone retains pending native structure and child bodies until an explicit remote choice", async () => { + const {c,node,left} = await movableTree("active-delete-race"), note=randomUUID(); + await c.repository.notes.create({id:note,notebookId:node.slice(9),contentFormat:"markdown",content:"Protected from cascade"}); + await c.sync(); await cachePersonalTree(c); + await c.repository.movePersonalTreeNodes([{id:node,parentId:left,sortOrder:7}]); + const before=(await c.db.query<{lastSequence:number}>("SELECT lastSequence FROM sync_state WHERE profileId=?",[c.profileId]))[0].lastSequence; + serverDb.prepare("DELETE FROM notebooks WHERE id=?").run(node.slice(9)); + await c.sync(); + assert.equal((await c.repository.notes.get(note))?.content,"Protected from cascade"); + assert.equal((await c.db.query<{lastSequence:number}>("SELECT lastSequence FROM sync_state WHERE profileId=?",[c.profileId]))[0].lastSequence,before); + const [conflict]=await c.db.query<{id:string;remotePayload:string}>("SELECT id,remotePayload FROM sync_conflicts WHERE profileId=? AND entityId=? AND status='unresolved'",[c.profileId,node]); + assert.ok(conflict); assert.equal(JSON.parse(conflict.remotePayload).__delete,true); + await assert.rejects(c.engine.resolveLocalConflict(conflict.id,"keep-local"),/业务资源缺失/); + await c.engine.resolveLocalConflict(conflict.id,"keep-remote"); await c.sync(); + assert.equal((await pending(c)).length,0); assert.equal((await conflicts(c)).length,0); + assert.equal(await c.repository.notes.get(note),null); +}); + test.before(async () => { const schema = await import("../src/db/schema"); const routes = await import("../src/routes/sync-v2"); diff --git a/frontend/src/lib/mobileSyncEngine.ts b/frontend/src/lib/mobileSyncEngine.ts index c45a2f400..4e8e7bc51 100644 --- a/frontend/src/lib/mobileSyncEngine.ts +++ b/frontend/src/lib/mobileSyncEngine.ts @@ -265,7 +265,20 @@ export class MobileSyncEngine { await writeNativePersonalTreeMoves(tx,[{id:row.entityId}],{profileId:this.options.profileId,deviceId:this.options.deviceId,userId:this.options.userId}); } const payload = resolution === "keep-remote" ? remote : local; - await applyNativePersonalTree(tx,[{entityId:row.entityId,payload}],this.options.userId,false); + if (resolution === "keep-remote" && remote.__delete) { + const descendants = await tx.query<{id:string;resourceType:string;resourceId:string}>(`WITH RECURSIVE subtree(id) AS ( + SELECT ? UNION SELECT t.id FROM native_knowledge_tree_nodes t JOIN subtree p ON t.parentId=p.id WHERE t.scopeKey='personal' + ) SELECT t.id,t.resourceType,t.resourceId FROM native_knowledge_tree_nodes t JOIN subtree s ON t.id=s.id WHERE t.scopeKey='personal'`,[row.entityId]); + for (const child of descendants) if (child.id !== row.entityId) { + const pending = await tx.query(`SELECT id FROM sync_outbox WHERE profileId=? AND scopeKey='personal' + AND ((entityType='knowledge_tree_node' AND entityId=?) OR (entityType=? AND entityId=?)) + UNION SELECT id FROM sync_conflicts WHERE profileId=? AND scopeKey='personal' AND status='unresolved' + AND ((entityType='knowledge_tree_node' AND entityId=?) OR (entityType=? AND entityId=?)) LIMIT 1`, + [this.options.profileId,child.id,child.resourceType,child.resourceId,this.options.profileId,child.id,child.resourceType,child.resourceId]); + if (pending.length) throw new Error("子节点仍有待同步修改或冲突,请先保留并处理这些内容"); + } + await applyNativePersonalTree(tx,descendants.map((child) => ({entityId:child.id,payload:{__delete:true}})),this.options.userId,false); + } else await applyNativePersonalTree(tx,[{entityId:row.entityId,payload}],this.options.userId,false); await syncNativeNotebookMoveParents(tx,[row.entityId]); await tx.run(`DELETE FROM sync_outbox WHERE profileId=? AND scopeKey='personal' AND entityType='knowledge_tree_node' AND entityId=?`, [this.options.profileId,row.entityId]); @@ -801,6 +814,28 @@ export class MobileSyncEngine { private async applyEntries(scope: ScopeDescriptor, entries: SnapshotEntry[], bootstrap: boolean): Promise { assertMobileSyncItems(entries,"snapshot",this.hasTreeSubscription(scope)); for (const entry of entries) if (entry.entityType === "note" && !entry.payload.__delete) validateEncryptedNoteWrite(entry.payload); + const treeDeletes = entries.filter((entry) => entry.entityType === "knowledge_tree_node" && entry.payload.__delete); + if (treeDeletes.length) { + const held = await this.options.db.transaction(async (tx) => { + let held = false; + for (const entry of treeDeletes) { + const conflict = (await tx.query<{id:string}>(`SELECT id FROM sync_conflicts WHERE profileId=? AND scopeKey='personal' + AND entityType='knowledge_tree_node' AND entityId=? AND status='unresolved' LIMIT 1`,[this.options.profileId,entry.entityId]))[0]; + const pending = await tx.query(`SELECT id FROM sync_outbox WHERE profileId=? AND scopeKey='personal' + AND entityType='knowledge_tree_node' AND entityId=? LIMIT 1`,[this.options.profileId,entry.entityId]); + if (!conflict && !pending.length) continue; + held = true; + if (conflict) await tx.run("UPDATE sync_conflicts SET remotePayload=? WHERE id=?",[JSON.stringify(entry.payload),conflict.id]); + else await tx.run(`INSERT INTO sync_conflicts (id,profileId,scopeKey,entityType,entityId,localPayload,remotePayload,status,createdAt) + VALUES (?,?,'personal','knowledge_tree_node',?,?,?,'unresolved',?)`, + [newLocalId(),this.options.profileId,entry.entityId,JSON.stringify(await readNativePersonalTreeNode(tx,entry.entityId)),JSON.stringify(entry.payload),now()]); + } + return held; + }); + // Keep the durable conflict, but apply no business deletion or cursor ACK. + // A notebook DELETE would otherwise cascade through retained child bodies. + if (held) throw syncFailure("VERSION_CONFLICT","远端目录已删除,请先在冲突中心处理本机位置和子节点内容"); + } const notebookParents=entries.flatMap((entry)=>entry.entityType==="notebook" && typeof entry.payload.parentId==="string" ? [[entry.entityId,entry.payload.parentId] as const] : []); const applied: SnapshotEntry[] = []; From 8119a34cbada30029c2281c636a0dc4fe4eb12d0 Mon Sep 17 00:00:00 2001 From: cropflre <767308865@qq.com> Date: Sun, 11 Oct 2026 20:03:09 +0800 Subject: [PATCH 09/13] docs(sync): record native folder writes and conflict recovery acceptance --- CHANGELOG.md | 3 +- .../data-consistency-contract.matrix.json | 4 ++ .../sync-v2-native-tree-pull-validation.en.md | 2 + docs/sync-v2-native-tree-pull-validation.md | 2 + ...ync-v2-native-tree-writes-validation.en.md | 17 ++++++++ docs/sync-v2-native-tree-writes-validation.md | 37 ++++++++++++++++ ...sync-v2-native-tree-writes-2026-10-11.json | 42 +++++++++++++++++++ frontend/src/lib/mobileSyncEngine.ts | 3 +- 8 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 docs/sync-v2-native-tree-writes-validation.en.md create mode 100644 docs/sync-v2-native-tree-writes-validation.md create mode 100644 docs/test-results/sync-v2-native-tree-writes-2026-10-11.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 08f200fd3..408840a7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,8 @@ ## 未发布 / Unreleased -- **sync**:Native 个人空间协商接收完整知识树结构,事务保存跨文档父子关系、移动与删除;升级重建订阅、首次 ACK 丢失保留基线。REST 树修改提交后唤醒 Pull,并修复移入根文档时隐藏容器校验失败。Receive negotiated personal tree structure on native clients, retain subscription intent across lost ACKs, notify after REST commits, and preserve hidden containers during root-document moves. Native structural writes and conflict UI remain pending. +- **sync**:个人普通文件夹的移动、批量移动与排序可先事务保存到 Native SQLite/Outbox,支持重启后上传和结构冲突选择;schema 7 无损升级队列,远端目录墓碑先保留本机位置与子正文。Persist ordinary personal folder moves and sorting atomically, replay after restart, and recover structural conflicts without cascading over retained child bodies. Document/mindmap/file moves and folder creation/trash/restore retain their existing online paths. 验收见 `docs/sync-v2-native-tree-writes-validation.md`。 +- **sync**:Native 个人空间协商接收完整知识树结构,事务保存跨文档父子关系、移动与删除;升级重建订阅、首次 ACK 丢失保留基线。REST 树修改提交后唤醒 Pull,并修复移入根文档时隐藏容器校验失败。Receive negotiated personal tree structure on native clients, retain subscription intent across lost ACKs, notify after REST commits, and preserve hidden containers during root-document moves. - **android**:个人知识树根级和普通文件夹内的 Markdown/富文本可先保存到本机并进入现有 Sync V2;将已授权的个人远端正文持久化,保护重启和后续离线编辑。Persist personal native tree documents and authorized missing bodies before subsequent offline edits; full shared/protected tree synchronization remains incomplete. - **sync**:保护 Native 离线编辑、删除冲突和迟到 ACK;重试保留 mutationId,避免旧更新复活已删除笔记。Protect durable native edits, deletion conflicts and late ACKs without changing mutation identity. - **android**:复用 WebSocket 唤醒 Sync V2 Pull,提交后刷新界面,并通过前台周期 Pull 补偿丢失通知。Wake native Pull on notices/reconnect and refresh UI after committed Apply. diff --git a/docs/architecture/data-consistency-contract.matrix.json b/docs/architecture/data-consistency-contract.matrix.json index c9945fe7f..439730882 100644 --- a/docs/architecture/data-consistency-contract.matrix.json +++ b/docs/architecture/data-consistency-contract.matrix.json @@ -80,6 +80,10 @@ "title": "冲突必须保留本机意图和服务端版本,不能静默覆盖", "status": "covered", "checks": [ + { + "file": "backend/tests/sync-v2-mobile-multidevice.test.ts", + "case": "a remote folder tombstone retains pending native structure and child bodies until an explicit remote choice" + }, { "file": "backend/tests/sync-v2-engine.test.ts", "case": "VERSION_CONFLICT 登记冲突台账并保留本地内容" diff --git a/docs/sync-v2-native-tree-pull-validation.en.md b/docs/sync-v2-native-tree-pull-validation.en.md index 28482c59b..04bfc597e 100644 --- a/docs/sync-v2-native-tree-pull-validation.en.md +++ b/docs/sync-v2-native-tree-pull-validation.en.md @@ -1,5 +1,7 @@ # Native personal tree structure reception and recovery +Historical reception batch; subsequent folder writes and structural conflict evidence are recorded in [the next batch](sync-v2-native-tree-writes-validation.en.md). + 2026-10-11, third reliability batch on `codex/native-knowledge-tree-offline`, continuing Draft PR #831 into `release/v1.5.2`. Baseline: `1b5b0c79`. [Chinese evidence](sync-v2-native-tree-pull-validation.md). Native previously rejected tree entities and could only project legacy notebook parents. The engine now negotiates the existing complete entity set through `/plan`, with a stable device ID, rebuilds a paginated personal Snapshot, then receives structural changes through the existing Pull path. Workspace subscriptions remain legacy. A fresh profile can use an old server; a persisted or pending tree subscription never silently downgrades. diff --git a/docs/sync-v2-native-tree-pull-validation.md b/docs/sync-v2-native-tree-pull-validation.md index 0bc271323..ce2558b23 100644 --- a/docs/sync-v2-native-tree-pull-validation.md +++ b/docs/sync-v2-native-tree-pull-validation.md @@ -1,5 +1,7 @@ # Native 个人知识树结构接收与恢复 +本页保留第三批接收验收;后续文件夹主动写入和结构冲突证据见[下一批记录](sync-v2-native-tree-writes-validation.md)。 + 2026-10-11,同步可靠性第三批,基准 `1b5b0c79`;分支 `codex/native-knowledge-tree-offline`,继续 PR #831,目标 `release/v1.5.2`。[English](sync-v2-native-tree-pull-validation.en.md)。 ## 问题与实际调用链 diff --git a/docs/sync-v2-native-tree-writes-validation.en.md b/docs/sync-v2-native-tree-writes-validation.en.md new file mode 100644 index 000000000..7111aa7a8 --- /dev/null +++ b/docs/sync-v2-native-tree-writes-validation.en.md @@ -0,0 +1,17 @@ +# Native personal folder writes and structural conflict recovery + +2026-10-11, continuing Draft [PR #831](https://github.com/cropflre/nowen-note/pull/831) on `codex/native-knowledge-tree-offline` into `release/v1.5.2`, baseline `24594972`. [Chinese evidence](sync-v2-native-tree-writes-validation.md), [previous reception batch](sync-v2-native-tree-pull-validation.en.md). + +Subscribed Native clients can move, batch-move and sort ordinary owned personal folders under the root, a folder or a document. Structure, compatible notebook parent/order and Outbox commit atomically; failed queue inserts or graph validation roll back every local move. Complete cached owner ancestry without folder passwords is required. Structural rows provide no ACL proof. Unavailable authorization retains the existing online path; storage failure never starts a second remote write. + +Schema 7 widens queue/conflict entity CHECKs without losing mutation identity, payloads, versions, retries or resolved history. Both rebuilds and the version change share a transaction. Push uses the existing baseParentId/baseSortOrder/baseIsDeleted CAS after complete Snapshot ACK binding. Lost ACK retries preserve mutation IDs. The server reuses its existing folder move service and updates compatible notebook order, preventing a subsequent rename from undoing document placement or sorting. + +Pull and Snapshot reconciliation preserve pending positions and recognize an intermediate queued position after an ambiguous ACK. The existing Native conflict endpoint now supports both structural choices: keep-local uses the latest durable position with the latest remote tuple; keep-remote sends no new mutation. Invalid parents, cycles or unavailable access fail without discarding the conflict. Permanent remote folder tombstones persist a conflict before any cascading business deletion and hold the page cursor/ACK. An explicit remote choice can remove its local subtree projection before replaying business deletes, provided child writes/conflicts have been handled. Missing resources cannot be resurrected. Personal Snapshots retain the existing business-resource retention policy. + +Final local evidence: 316 relevant backend tests and 254 frontend tests across 39 files, zero skips. All 8 backend contract-mapped files also passed 118 tests, including backup integrity/rollback and workspace deletion protection; these overlap and are not added to the first count. Backend build/build:tsc, frontend TypeScript and normal build passed. Lint reports 1447 historical errors and zero new errors. Normal dist excludes the acceptance entry. Contract verification: 12 invariants, 87 references, 8 backend and 19 frontend files. Node 24 actually executes SQLite cases; Windows better-sqlite3 uses the repository Electron runtime. Remote CI must be checked for the final SHA. + +Android acceptance used a read-only Pixel_8_API_35 emulator and separate `com.nowen.note.syncacceptance` package, API 35 / WebView 124.0.6367.219, empty crash buffer. The connected PJZ110 was untouched. Frontend/backend source: `053b2ae1`; APK SHA256 `945c13887763dea695733833e706b0cc2f5dc4f52aadfdb98cc878c23645bc4a`. Later permanent-deletion protection has actual multi-client SQLite coverage; this APK is not device proof for that follow-up change. + +The production API Bridge passed offline folder movement/sorting and navigation, force-stop/reopen with the same pending Outbox, automatic upload after reconnection, concurrent server movement and keep-local recovery through the Native conflict service. Root Markdown/rich-text creation, bidirectional body/receipt updates, missed-notice polling and reconnection also passed. No database or Outbox was cleared. Thirty loopback body samples measured HTTP→Native P50/P95/P99 306/327/330 ms and Native→server+ACK 493/510/544 ms. Remote folder creation/movement were single samples at 301/267 ms; these are not WAN, full-editor or folder-write percentiles. [Machine record](test-results/sync-v2-native-tree-writes-2026-10-11.json). + +Native folder creation/trash/restore, document/mindmap/file moves coupled to body/attachment queues, protected/shared scopes and permission changes remain future work. Full ConflictCenter/EditorPane GUI, actual old-user database device migration, mobile browser, attachment bytes, WAN, iOS/HarmonyOS, NAS and released packages are not accepted here. Keep the PR Draft; do not close #829, merge or release. diff --git a/docs/sync-v2-native-tree-writes-validation.md b/docs/sync-v2-native-tree-writes-validation.md new file mode 100644 index 000000000..97b1f7845 --- /dev/null +++ b/docs/sync-v2-native-tree-writes-validation.md @@ -0,0 +1,37 @@ +# Native 个人文件夹写入与结构冲突恢复 + +2026-10-11,`codex/native-knowledge-tree-offline`,继续 Draft PR [#831](https://github.com/cropflre/nowen-note/pull/831),目标 `release/v1.5.2`。本批基准 `24594972`;[上一批接收验收](sync-v2-native-tree-pull-validation.md)、[English](sync-v2-native-tree-writes-validation.en.md)。 + +## 已实现的闭环 + +已完成完整订阅的 Native 客户端,可以在个人空间移动普通文件夹、批量移动和排序,目标可以是根、普通文件夹或文档。整个本地批次的结构、兼容 notebook.parentId/sortOrder 与 Outbox 在同一事务中落库,任一入队或图校验失败均回滚;提交后才唤醒同步。已有服务端权限快照必须证明完整父链归属个人 owner 且无文件夹密码。结构表自身没有 ACL/密码,不作为授权证明;不足时走既有在线路径。存储提交失败直接报错,不重复发起另一个在线写入。 + +schema 7 在一个迁移事务内无损重建 Outbox/Conflict CHECK,保留 mutationId、版本、完整 payload、重试和已解决历史;失败保留旧表及版本。结构 Push 沿用服务端 baseParentId/baseSortOrder/baseIsDeleted,等待完整 Snapshot ACK 绑定后才发送。顺序移动不合并或改写已发送身份,丢失 ACK 重试仍使用原 mutationId。服务端文件夹结构 Apply 复用现有移动服务,同步物理容器与排序,避免下一次重命名把文档父节点或排序覆盖回旧值。 + +增量及全量重建保留未发送的本机位置;全量远端图先校验,再在同一事务内恢复并协调本机意图。中间已应用但 ACK 丢失的位置可识别为相同队列意图。并发移动进入已有冲突中心接口,保留双方结构;保留本机采用最新落库位置并基于最新远端 tuple 重新入队,保留服务器不再次上传。权限不足、缺失父节点或成环的选择保持失败和原记录。 + +若远端永久删除了仍有本机结构意图的目录,先持久化结构冲突,整页业务 Apply 和 ACK 暂停,避免 notebook DELETE 级联清掉子正文。不能以保留本机复活已不存在的资源;明确选择服务器可移除本地子树投影,随后重放原 Feed 的业务删除。子节点仍有待发送数据或未处理冲突时拒绝该选择,要求先保留并处理内容。全量个人 Snapshot 沿用既有业务资源保留策略,不把快照缺席直接作为物理删除。 + +## 当前验证 + +- 最终相关后端回归 316 项、前端去重回归 254 项(39 文件)通过,均无跳过。包括两库并发移动两种选择、冲突期间继续移动、丢失 ACK/中间回显、Snapshot 重建、循环/密码/未订阅拒绝、Outbox 插入失败、实际 SQLite schema 6→7 迁移及第二表失败回滚、远端目录删除保留子正文。 +- 契约矩阵全部 8 个后端映射文件另跑 118 项通过、无跳过,含备份完整性/恢复回滚和工作区删除保护;与上项有重叠,不相加作为总数。 +- 后端生产 build/build:tsc、前端 TypeScript/正常生产 build 通过;lint 1447 历史错误、0 新错误。正常 dist 不含 Android 同步验收入口。契约门禁 12 不变量、87 引用、8 后端文件、19 前端文件。 +- 本机测试采用 Node 24;better-sqlite3 使用项目 Electron RunAsNode。Android Local-first CI 改为 Node 24 实际执行迁移用例;CI 状态须看最终 SHA,不把本地通过当作远端通过。 + +## Android 实测边界 + +隔离的只读 Pixel_8_API_35 模拟器、`com.nowen.note.syncacceptance` 独立包,API 35 / WebView 124.0.6367.219,崩溃缓冲 0 行。连接的 PJZ110 未安装或测试。前端及服务端验收源 `053b2ae1`,APK SHA256 `945c13887763dea695733833e706b0cc2f5dc4f52aadfdb98cc878c23645bc4a`;之后的永久删除竞争保护由真实多客户端 SQLite 回归覆盖,本 APK 不作为该新增保护的设备证明。 + +实际执行生产 knowledgeTreeApi Bridge:切断 adb reverse 后移动/排序文件夹,离线列表显示待发送位置,force-stop/reopen 后结构和队列保留,恢复网络自动上传;再制造与服务端并发移动并通过同一 Native 冲突服务保留本机,最终服务端和本机收敛。另复测根级 MD/富文本创建、正文双向更新/收据、通知丢失周期补偿与重连。没有清库或更换客户端 Outbox。 + +| 回环 30 次样本 | P50 / P95 / P99 | +| --- | --- | +| HTTP Push → Native API 读取 | 306 / 327 / 330 ms | +| Native 提交 → 服务端正文 + Native ACK | 493 / 510 / 544 ms | + +远端文件夹创建/移动单次 301/267 ms。这些正文分位数和单次结构样本包含轮询/API 开销,不能代表公网、完整编辑器或文件夹写入的延迟分位数。[机器记录](test-results/sync-v2-native-tree-writes-2026-10-11.json)。 + +## 仍需后续阶段 + +本批只启用普通个人文件夹移动/排序。Native 主动文件夹创建、删除/恢复,文档/脑图/文件移动与正文/附件队列联动仍走既有在线路径;共享、密码目录与权限变更仍需专项验收。完整手机 ConflictCenter/EditorPane GUI、真实旧用户库升级、手机浏览器、附件 bytes、WAN、iOS/HarmonyOS、NAS/正式包均未由本轮证明。保留 Draft,不关闭 #829,不合并或发布。 diff --git a/docs/test-results/sync-v2-native-tree-writes-2026-10-11.json b/docs/test-results/sync-v2-native-tree-writes-2026-10-11.json new file mode 100644 index 000000000..654620299 --- /dev/null +++ b/docs/test-results/sync-v2-native-tree-writes-2026-10-11.json @@ -0,0 +1,42 @@ +{ + "device": "emulator-5580", + "package": "com.nowen.note.syncacceptance", + "offlineImmediateRead": true, + "forceStopRecovery": true, + "knowledgeTreeRootCreate": true, + "readWriteThroughProductionApiBridge": true, + "negotiatedPersonalTreePull": true, + "remoteFolderUnderDocument": true, + "remoteTreeMove": true, + "structureForceStopRecovery": true, + "nativeSchemaVersion": 7, + "nativeOfflineTreeMoveAndSort": true, + "pendingTreeForceStopRecovery": true, + "automaticStructurePush": true, + "nativeConcurrentMoveKeepLocal": true, + "missingTreeNoticePoll": true, + "remoteFolderCreateMs": 301, + "remoteTreeMoveMs": 267, + "offlineNetworkIsolation": "adb reverse removed; HTTP server unreachable from Android", + "markdownAndRichText": true, + "missingNoticePoll": true, + "reconnect": true, + "httpPushToNativePull": { + "samples": 30, + "p50Ms": 306, + "p95Ms": 327, + "p99Ms": 330 + }, + "nativeCommitToServerAndAck": { + "samples": 30, + "p50Ms": 493, + "p95Ms": 510, + "p99Ms": 544 + }, + "errors": [ + { + "lastError": null + } + ], + "boundary": "Loopback adb reverse; actual Android Native SQLite, production API Bridge personal tree move/sort, Outbox restart recovery, automatic Push and concurrent move keep-local via the Native conflict service. Full ConflictCenter GUI, EditorPane, protected/shared ACL, attachment bytes and full production JWT middleware are not accepted here." +} \ No newline at end of file diff --git a/frontend/src/lib/mobileSyncEngine.ts b/frontend/src/lib/mobileSyncEngine.ts index 4e8e7bc51..0eb676dd9 100644 --- a/frontend/src/lib/mobileSyncEngine.ts +++ b/frontend/src/lib/mobileSyncEngine.ts @@ -270,11 +270,12 @@ export class MobileSyncEngine { SELECT ? UNION SELECT t.id FROM native_knowledge_tree_nodes t JOIN subtree p ON t.parentId=p.id WHERE t.scopeKey='personal' ) SELECT t.id,t.resourceType,t.resourceId FROM native_knowledge_tree_nodes t JOIN subtree s ON t.id=s.id WHERE t.scopeKey='personal'`,[row.entityId]); for (const child of descendants) if (child.id !== row.entityId) { + const entityType = child.resourceType === "file" ? "attachment" : child.resourceType; const pending = await tx.query(`SELECT id FROM sync_outbox WHERE profileId=? AND scopeKey='personal' AND ((entityType='knowledge_tree_node' AND entityId=?) OR (entityType=? AND entityId=?)) UNION SELECT id FROM sync_conflicts WHERE profileId=? AND scopeKey='personal' AND status='unresolved' AND ((entityType='knowledge_tree_node' AND entityId=?) OR (entityType=? AND entityId=?)) LIMIT 1`, - [this.options.profileId,child.id,child.resourceType,child.resourceId,this.options.profileId,child.id,child.resourceType,child.resourceId]); + [this.options.profileId,child.id,entityType,child.resourceId,this.options.profileId,child.id,entityType,child.resourceId]); if (pending.length) throw new Error("子节点仍有待同步修改或冲突,请先保留并处理这些内容"); } await applyNativePersonalTree(tx,descendants.map((child) => ({entityId:child.id,payload:{__delete:true}})),this.options.userId,false); From 61ca1f03d4fd057e9cc2dc818a599a0febb33423 Mon Sep 17 00:00:00 2001 From: cropflre <767308865@qq.com> Date: Sun, 11 Oct 2026 20:25:09 +0800 Subject: [PATCH 10/13] feat(sync): persist native personal folder creation and ordered structure intents --- backend/src/sync/apply.ts | 12 ++ .../tests/sync-v2-mobile-multidevice.test.ts | 104 ++++++++++++++++ .../data-consistency-contract.matrix.json | 9 ++ .../mobileLocalFirstPasswordAccess.test.ts | 1 + .../mobileLocalKnowledgeTreeBridge.test.ts | 1 + .../mobileNativeTreeWritesBridge.test.ts | 39 +++++- .../mobileSignedInTreeCreate.test.ts | 1 + .../src/lib/mobileLocalKnowledgeTreeBridge.ts | 51 +++++--- frontend/src/lib/nativeLocalRepository.ts | 18 ++- frontend/src/lib/nativePersonalTreeWrites.ts | 114 ++++++++++++++---- frontend/tests/android-sync/main.ts | 10 ++ scripts/android-sync-acceptance.mjs | 41 ++++++- 12 files changed, 355 insertions(+), 46 deletions(-) diff --git a/backend/src/sync/apply.ts b/backend/src/sync/apply.ts index fb560ce94..2b92a49d8 100644 --- a/backend/src/sync/apply.ts +++ b/backend/src/sync/apply.ts @@ -7,6 +7,7 @@ import type { SyncEntityType, SyncOperation } from "./types"; import { normalizeNoteThemeId } from "../lib/noteThemeId"; import { normalizeNoteColorMark } from "../lib/noteColorMark"; import { applyKnowledgeTreeStructureMutation } from "./knowledgeTreeStructure.js"; +import { isRootDocumentNotebookId } from "../services/knowledgeTreeRootDocuments.js"; /** * Sync V2 服务端 mutation 应用层。 @@ -113,6 +114,17 @@ function applyNotebook(db: Database.Database, input: ApplyMutationInput): number } const p = input.payload || {}; + // Existing folders under root documents retain a hidden compatibility + // container. UPSERT would rerun the creation guard against that deleted + // container even for a rename; update state without reassigning its parent. + if (isRootDocumentNotebookId(str(p.parentId)) && db.prepare(`SELECT 1 FROM notebooks + WHERE id=? AND parentId=? AND workspaceId IS ? AND (? IS NOT NULL OR userId=?)`) + .get(input.entityId,p.parentId,workspaceIdOf(input),workspaceIdOf(input),input.userId)) { + db.prepare(`UPDATE notebooks SET name=?,description=?,icon=?,color=?,sortOrder=?,isExpanded=?, + isDeleted=?,deletedAt=?,updatedAt=datetime('now') WHERE id=?`).run(str(p.name,"未命名笔记本"),p.description??null, + str(p.icon,"📒"),p.color??null,num(p.sortOrder),bit(p.isExpanded??1),bit(p.isDeleted),p.deletedAt??null,input.entityId); + return null; + } db.prepare(` INSERT INTO notebooks ( id, userId, parentId, name, description, icon, color, diff --git a/backend/tests/sync-v2-mobile-multidevice.test.ts b/backend/tests/sync-v2-mobile-multidevice.test.ts index d38063de2..e095403ad 100644 --- a/backend/tests/sync-v2-mobile-multidevice.test.ts +++ b/backend/tests/sync-v2-mobile-multidevice.test.ts @@ -112,6 +112,110 @@ async function movableTree(name: string) { return { c, node: `notebook:${ids[0]}`, left: `notebook:${ids[1]}`, right: `notebook:${ids[2]}`, document: `note:${document}` }; } +test("offline folder creation atomically persists nested resources and document parents across reopen and a second device", async () => { + const {c,document}=await movableTree("folder-create"), root=randomUUID(), child=randomUUID(), underDocument=randomUUID(), note=randomUUID(); + await c.repository.createPersonalTreeFolder(root,"Offline root",null); + await c.repository.createPersonalTreeFolder(child,"Offline child",`notebook:${root}`); + await c.repository.createPersonalTreeFolder(underDocument,"Under document",document); + await c.repository.createPersonalTreeNote({id:note,contentFormat:"markdown",content:"Nested offline body"},child); + const intents=await pending(c); assert.equal(intents.length,7); + assert.equal(JSON.parse(intents[5].payload).baseParentId,null); + assert.equal(JSON.parse(intents[5].payload).parentId,document); + assert.equal((await c.repository.listPersonalTreeFolderDrafts()).length,3); + await c.db.close(); const reopened=await client("folder-create"); + assert.deepEqual((await pending(reopened)).map((row)=>row.mutationId),intents.map((row)=>row.mutationId)); + assert.equal((await reopened.repository.notes.get(note))?.content,"Nested offline body"); + await reopened.sync(); + const peer=await client("folder-create-peer"); await peer.sync(); + assert.equal((await pending(reopened)).length,0); assert.equal((await conflicts(reopened)).length,0); + for (const device of [reopened,peer]) { + const graph=await device.db.query<{id:string;parentId:string|null}>("SELECT id,parentId FROM native_knowledge_tree_nodes WHERE id IN (?,?,?)",[`notebook:${root}`,`notebook:${child}`,`notebook:${underDocument}`]); + assert.equal(graph.find((row)=>row.id===`notebook:${root}`)?.parentId,null); + assert.equal(graph.find((row)=>row.id===`notebook:${child}`)?.parentId,`notebook:${root}`); + assert.equal(graph.find((row)=>row.id===`notebook:${underDocument}`)?.parentId,document); + assert.equal((await device.repository.notes.get(note))?.content,"Nested offline body"); + } + await reopened.repository.notebooks.update(underDocument,{name:"Renamed"}); await reopened.sync(); + assert.equal((await pending(reopened)).length,0); + assert.equal(serverDb.prepare("SELECT parentId FROM knowledge_tree_nodes WHERE id=?").get(`notebook:${underDocument}`).parentId,document); +}); + +test("lost creation ACK replays immutable mutations before later offline nested creation and moves", async () => { + const {c,document}=await movableTree("folder-create-lost-ack"), id=randomUUID(), child=randomUUID(); + await c.repository.createPersonalTreeFolder(id,"Before ACK",document); + const original=(await pending(c)).map((row)=>row.mutationId); + afterPush=async()=>{ + await c.repository.createPersonalTreeFolder(child,"During ACK",`notebook:${id}`); + await c.repository.movePersonalTreeNodes([{id:`notebook:${id}`,parentId:null,sortOrder:41}]); + return Response.json({code:"SERVER_ERROR"},{status:503}); + }; + try {await c.sync();} finally {afterPush=undefined;} + assert.deepEqual((await pending(c)).slice(0,2).map((row)=>row.mutationId),original); + await c.db.close(); const reopened=await client("folder-create-lost-ack"); + await reopened.db.run("UPDATE sync_state SET lastSequence=0 WHERE profileId=?",[reopened.profileId]); + await reopened.sync(); assert.equal((await pending(reopened)).length,0); assert.equal((await conflicts(reopened)).length,0); + assert.deepEqual(serverDb.prepare("SELECT parentId,sortOrder FROM knowledge_tree_nodes WHERE id=?").get(`notebook:${id}`),{parentId:null,sortOrder:41}); + assert.equal(serverDb.prepare("SELECT parentId FROM knowledge_tree_nodes WHERE id=?").get(`notebook:${child}`).parentId,`notebook:${id}`); +}); + +test("folder creation fails closed for unbound, missing, foreign and newly protected ancestor proofs", async () => { + const {c,node,left}=await movableTree("folder-create-guards"); + const cached=(await c.db.query<{value:string}>("SELECT value FROM native_runtime_meta WHERE key='knowledgeTreeSnapshot:v1:personal:0'"))[0]; + const nodes=JSON.parse(cached.value) as Array<{id:string;userId:string;isPasswordProtected:number}>; + await assert.rejects(c.repository.createPersonalTreeFolder(randomUUID(),"Missing","notebook:missing"),/权限|密码/); + nodes.find((n)=>n.id===left)!.userId="other-user"; + await c.db.run("UPDATE native_runtime_meta SET value=? WHERE key='knowledgeTreeSnapshot:v1:personal:0'",[JSON.stringify(nodes)]); + await assert.rejects(c.repository.createPersonalTreeFolder(randomUUID(),"Foreign",left),/权限|密码/); + await cachePersonalTree(c); + await c.repository.movePersonalTreeNodes([{id:node,parentId:left}]); + await c.repository.movePersonalTreeNodes([{id:node,parentId:null}]); + const changed=JSON.parse(cached.value) as Array<{id:string;parentId:string|null;isPasswordProtected:number}>; + changed.find((n)=>n.id===node)!.parentId=left; changed.find((n)=>n.id===left)!.isPasswordProtected=1; + await c.db.run("UPDATE native_runtime_meta SET value=? WHERE key='knowledgeTreeSnapshot:v1:personal:0'",[JSON.stringify(changed)]); + await assert.rejects(c.repository.createPersonalTreeFolder(randomUUID(),"Protected source",node),/权限|密码/); + await c.db.run("DELETE FROM native_runtime_meta WHERE key=?",[`nativeTreeSubscription:${c.profileId}:personal`]); + await assert.rejects(c.repository.createPersonalTreeFolder(randomUUID(),"Unbound",null),/订阅/); + assert.equal((await c.repository.listPersonalTreeFolderDrafts()).length,0); assert.equal((await pending(c)).length,2); +}); + +test("a lost structural creation ACK retains the local folder for further nested writes and snapshot recovery", async () => { + const {c,document}=await movableTree("folder-create-partial-ack"), id=randomUUID(), child=randomUUID(); + await c.repository.createPersonalTreeFolder(id,"Partial ACK",document); + const treeId=(await c.db.query<{mutationId:string}>("SELECT mutationId FROM sync_outbox WHERE entityType='knowledge_tree_node'"))[0].mutationId; + await c.db.run("UPDATE sync_state SET lastSequence=0 WHERE profileId=?",[c.profileId]); + afterPush=async (response)=>{ + const body=await response.json() as {results:Array<{mutationId:string}>}; + return Response.json({...body,results:body.results.map((row)=>row.mutationId===treeId?{...row,status:"failed",code:"NETWORK_UNAVAILABLE"}:row)}); + }; + try {await c.sync();} finally {afterPush=undefined;} + assert.deepEqual((await pending(c)).map((row)=>row.mutationId),[treeId]); + await c.repository.createPersonalTreeFolder(child,"Still offline",`notebook:${id}`); + await c.db.close(); const reopened=await client("folder-create-partial-ack"); await reopened.sync(); + assert.equal((await pending(reopened)).length,0); assert.equal((await conflicts(reopened)).length,0); + assert.equal(serverDb.prepare("SELECT parentId FROM knowledge_tree_nodes WHERE id=?").get(`notebook:${child}`).parentId,`notebook:${id}`); +}); + +test("creation dependencies remain ordered when folder mutations span the 100-row Push boundary", async () => { + const {c}=await movableTree("folder-create-page"), ids:string[]=[]; + for(let i=0;i<51;i++) { + const id=randomUUID(); await c.repository.createPersonalTreeFolder(id,"Paged folder",ids.length?`notebook:${ids.at(-1)}`:null); ids.push(id); + } + assert.equal((await pending(c)).length,102); + await c.sync(); await c.sync(); + assert.equal((await pending(c)).length,0); assert.equal((await conflicts(c)).length,0); + assert.equal(serverDb.prepare("SELECT parentId FROM knowledge_tree_nodes WHERE id=?").get(`notebook:${ids.at(-1)}`).parentId,`notebook:${ids.at(-2)}`); +}); + +test("offline folder creation rolls back the business row, structure, origin and both queues on a structural enqueue failure", async () => { + const {c}=await movableTree("folder-create-rollback"), id=randomUUID(); + await c.db.run("CREATE TRIGGER reject_folder_create BEFORE INSERT ON sync_outbox WHEN NEW.entityType='knowledge_tree_node' BEGIN SELECT RAISE(ABORT,'Folder Outbox failure'); END"); + await assert.rejects(c.repository.createPersonalTreeFolder(id,"Unsaved",null),/Folder Outbox failure/); + assert.equal(await c.repository.notebooks.get(id),null); + assert.equal((await c.db.query("SELECT id FROM native_knowledge_tree_nodes WHERE id=?",[`notebook:${id}`])).length,0); + assert.equal((await c.repository.listPersonalTreeFolderDrafts()).length,0); + assert.equal((await pending(c)).length,0); +}); + test("native personal moves and sorting are atomic, survive reopen and push only structural fields", async () => { const { c, node, left, document } = await movableTree("active-offline"); await c.repository.movePersonalTreeNodes([{ id: node, parentId: document, sortOrder: 23 }, { id: left, sortOrder: 24 }]); diff --git a/docs/architecture/data-consistency-contract.matrix.json b/docs/architecture/data-consistency-contract.matrix.json index 439730882..e919dd3da 100644 --- a/docs/architecture/data-consistency-contract.matrix.json +++ b/docs/architecture/data-consistency-contract.matrix.json @@ -317,6 +317,15 @@ { "file": "frontend/src/lib/encryptedNotes/__tests__/nativeDatabaseInitialization.test.ts", "case": "rolls back a failed schema 5 tree migration" }, { "file": "backend/tests/sync-v2-mobile-multidevice.test.ts", "case": "lost initial tree ACK retains subscription intent" }, { "file": "backend/tests/sync-v2-mobile-multidevice.test.ts", "case": "REST tree movement emits a committed metadata-only Sync V2 notice" }, + { "file": "backend/tests/sync-v2-mobile-multidevice.test.ts", "case": "offline folder creation atomically persists nested resources" }, + { "file": "backend/tests/sync-v2-mobile-multidevice.test.ts", "case": "offline folder creation rolls back the business row" }, + { "file": "backend/tests/sync-v2-mobile-multidevice.test.ts", "case": "lost creation ACK replays immutable mutations" }, + { "file": "backend/tests/sync-v2-mobile-multidevice.test.ts", "case": "folder creation fails closed for unbound" }, + { "file": "backend/tests/sync-v2-mobile-multidevice.test.ts", "case": "a lost structural creation ACK retains the local folder" }, + { "file": "backend/tests/sync-v2-mobile-multidevice.test.ts", "case": "creation dependencies remain ordered" }, + { "file": "frontend/src/lib/__tests__/mobileNativeTreeWritesBridge.test.ts", "case": "creates personal folders through the transaction" }, + { "file": "frontend/src/lib/__tests__/mobileNativeTreeWritesBridge.test.ts", "case": "restores reverse-ordered provisional nested folders" }, + { "file": "frontend/src/lib/__tests__/mobileNativeTreeWritesBridge.test.ts", "case": "does not grant provisional owner access to an ACKed folder" }, { "file": "frontend/src/lib/__tests__/mobileNativeTreeSubscription.test.ts", "case": "preserves subscription intent after a lost initial ACK" }, { "file": "backend/tests/knowledge-tree-root-documents.test.ts", "case": "moving folders and documents into root documents retains hidden containers" } ] diff --git a/frontend/src/lib/__tests__/mobileLocalFirstPasswordAccess.test.ts b/frontend/src/lib/__tests__/mobileLocalFirstPasswordAccess.test.ts index 608a61725..97f54ff0a 100644 --- a/frontend/src/lib/__tests__/mobileLocalFirstPasswordAccess.test.ts +++ b/frontend/src/lib/__tests__/mobileLocalFirstPasswordAccess.test.ts @@ -39,6 +39,7 @@ function setup(deviceOnly = false) { const repository = { listLocalTreeDrafts: vi.fn(async () => []), listPersonalTreeOverrides: vi.fn(async () => []), + listPersonalTreeFolderDrafts: vi.fn(async () => []), listNotebooksForWorkspace: vi.fn(async () => []), hydrateAuthorizedNote: vi.fn(async (incoming: Note) => incoming), notes: { get: vi.fn(async (id: string) => notes.find((item) => item.id === id)) }, diff --git a/frontend/src/lib/__tests__/mobileLocalKnowledgeTreeBridge.test.ts b/frontend/src/lib/__tests__/mobileLocalKnowledgeTreeBridge.test.ts index 58e5d922b..41b4946b0 100644 --- a/frontend/src/lib/__tests__/mobileLocalKnowledgeTreeBridge.test.ts +++ b/frontend/src/lib/__tests__/mobileLocalKnowledgeTreeBridge.test.ts @@ -70,6 +70,7 @@ function createRepository() { const repository = { listPersonalTreeOverrides: vi.fn(async () => []), + listPersonalTreeFolderDrafts: vi.fn(async () => []), listLocalTreeDrafts: vi.fn(async () => []), listNotebooksForWorkspace: vi.fn(async () => notebooks), listNotesForWorkspace: vi.fn(async (_workspaceId?: string, query?: { includeTrashed?: boolean }) => ( diff --git a/frontend/src/lib/__tests__/mobileNativeTreeWritesBridge.test.ts b/frontend/src/lib/__tests__/mobileNativeTreeWritesBridge.test.ts index 005e37782..cf4462537 100644 --- a/frontend/src/lib/__tests__/mobileNativeTreeWritesBridge.test.ts +++ b/frontend/src/lib/__tests__/mobileNativeTreeWritesBridge.test.ts @@ -19,13 +19,44 @@ function setup() { const remoteList = vi.spyOn(knowledgeTreeApi,"listForWorkspace").mockResolvedValue({nodes:[a,b]}); const moves = vi.fn(async () => [{...a,parentId:b.id,sortOrder:7}]); const overrides = vi.fn(async () => [] as Array>); - const repository = {movePersonalTreeNodes:moves,listPersonalTreeOverrides:overrides,listLocalTreeDrafts:async()=>[]} as unknown as NativeLocalRepository; + const folderDrafts=vi.fn(async()=>[] as KnowledgeTreeNode[]), createFolder=vi.fn(async()=>a); + const remoteCreate=vi.spyOn(knowledgeTreeApi,"createForWorkspace").mockResolvedValue(a); + const repository = {movePersonalTreeNodes:moves,listPersonalTreeOverrides:overrides,listPersonalTreeFolderDrafts:folderDrafts,createPersonalTreeFolder:createFolder,listLocalTreeDrafts:async()=>[]} as unknown as NativeLocalRepository; const metadata = new Map(); - const db = {run:async(_sql:string,values:unknown[])=>{metadata.set(String(values[0]),String(values[1]));return {changes:1};}, - query:async(_sql:string,values:unknown[])=>metadata.has(String(values[0]))?[{value:metadata.get(String(values[0]))}]:[]} as unknown as NativeDatabase; + let pending=true; + const db = {run:async(sql:string,values:unknown[])=>{if(sql.startsWith("DELETE"))metadata.delete(String(values[0]));else metadata.set(String(values[0]),String(values[1]));return {changes:1};}, + query:async(sql:string,values:unknown[])=>sql.includes("sync_outbox")?(pending?[{id:"queued"}]:[]):metadata.has(String(values[0]))?[{value:metadata.get(String(values[0]))}]:[]} as unknown as NativeDatabase; restore = installMobileLocalKnowledgeTreeBridge(repository,{deviceOnly:false},db); - return {a,b,moves,overrides,remoteMove,remoteReorder,remoteList}; + return {a,b,moves,overrides,remoteMove,remoteReorder,remoteList,folderDrafts,createFolder,remoteCreate,metadata,ack:()=>{pending=false;}}; } +it("creates personal folders through the transaction and preserves online fallback for unavailable proofs only",async()=>{ + const f=setup(); + await knowledgeTreeApi.createForWorkspace("personal",{parentId:f.b.id,nodeType:"folder",title:" Offline "}); + expect(f.createFolder).toHaveBeenCalledWith(expect.any(String),"Offline",f.b.id); expect(f.remoteCreate).not.toHaveBeenCalled(); + f.createFolder.mockRejectedValueOnce(Object.assign(new Error("Unbound"),{code:"NATIVE_TREE_WRITE_UNAVAILABLE"})); + await knowledgeTreeApi.createForWorkspace("personal",{parentId:null,nodeType:"folder",title:"Online"}); + expect(f.remoteCreate).toHaveBeenCalledOnce(); + f.createFolder.mockRejectedValueOnce(new Error("Outbox failed")); + await expect(knowledgeTreeApi.createForWorkspace("personal",{parentId:null,nodeType:"folder",title:"Failure"})).rejects.toThrow("Outbox failed"); + expect(f.remoteCreate).toHaveBeenCalledOnce(); + await knowledgeTreeApi.createForWorkspace("workspace-a",{parentId:null,nodeType:"folder",title:"Workspace"}); + expect(f.createFolder).toHaveBeenCalledTimes(3); expect(f.remoteCreate).toHaveBeenCalledTimes(2); +}); +it("restores reverse-ordered provisional nested folders over an offline cached tree and respects new password flags",async()=>{ + const f=setup(); await knowledgeTreeApi.listForWorkspace("personal"); + f.folderDrafts.mockResolvedValue([node("notebook:child","notebook:root"),node("notebook:root",f.b.id)]); + f.remoteList.mockRejectedValueOnce(new TypeError("Failed to fetch")); + const tree=await knowledgeTreeApi.listForWorkspace("personal"); + expect(tree.nodes.find((n)=>n.id==="notebook:child")?.parentId).toBe("notebook:root"); + expect(tree.nodes.find((n)=>n.id==="notebook:root")?.childCount).toBe(1); + expect(tree.nodes.find((n)=>n.id===f.b.id)?.childCount).toBe(1); + f.remoteList.mockResolvedValue({nodes:[f.a,{...f.b,isPasswordProtected:1}]}); + expect((await knowledgeTreeApi.listForWorkspace("personal")).nodes).toHaveLength(2); +}); +it("does not grant provisional owner access to an ACKed folder omitted by a live tree",async()=>{ + const f=setup(); f.folderDrafts.mockResolvedValue([node("notebook:new")]); f.ack(); + expect((await knowledgeTreeApi.listForWorkspace("personal")).nodes).toHaveLength(2); +}); it("routes personal folder move, batch move and reorder through the durable transaction without HTTP", async () => { const f=setup(); expect(await knowledgeTreeApi.move(f.a.id,{parentId:f.b.id,sortOrder:7})).toMatchObject({parentId:f.b.id,sortOrder:7}); diff --git a/frontend/src/lib/__tests__/mobileSignedInTreeCreate.test.ts b/frontend/src/lib/__tests__/mobileSignedInTreeCreate.test.ts index ba1f330a0..83dea028a 100644 --- a/frontend/src/lib/__tests__/mobileSignedInTreeCreate.test.ts +++ b/frontend/src/lib/__tests__/mobileSignedInTreeCreate.test.ts @@ -23,6 +23,7 @@ function setup(parent?: KnowledgeTreeNode) { } as unknown as NativeDatabase; const repository = { listPersonalTreeOverrides: vi.fn(async () => []), + listPersonalTreeFolderDrafts: vi.fn(async () => []), listLocalTreeDrafts: vi.fn(async () => drafts), createPersonalTreeNote: vi.fn(async (input: Note, notebookId: string | null) => { drafts.push({ ...input, userId: "user", workspaceId: null, notebookId: notebookId || "hidden-root", diff --git a/frontend/src/lib/mobileLocalKnowledgeTreeBridge.ts b/frontend/src/lib/mobileLocalKnowledgeTreeBridge.ts index 6b8871ebf..fbfd75cee 100644 --- a/frontend/src/lib/mobileLocalKnowledgeTreeBridge.ts +++ b/frontend/src/lib/mobileLocalKnowledgeTreeBridge.ts @@ -203,21 +203,34 @@ export function installMobileLocalKnowledgeTreeBridge( if (!deviceOnly) { const withLocalDrafts = async (result: KnowledgeTreeResponse, scope: string, online: boolean): Promise => { if (!db || scope !== "personal") return result; + const folderDrafts = await repository.listPersonalTreeFolderDrafts(); + const provisional: KnowledgeTreeNode[] = []; + for (const draft of folderDrafts) { + const present = result.nodes.some((node) => node.id === draft.id); + const pending = online && !present ? (await db.query("SELECT id FROM sync_outbox WHERE scopeKey='personal' AND entityType='notebook' AND entityId=? LIMIT 1",[draft.resourceId])).length : true; + if (present || !pending) { + if (online) await db.run("DELETE FROM native_runtime_meta WHERE key=?",[`localTreeFolder:personal:${draft.resourceId}`]); + continue; + } + provisional.push(draft); + } + const safeChain = (node: KnowledgeTreeNode, nodes: KnowledgeTreeNode[]) => { + const byId = new Map(nodes.map((item) => [item.id,item])); + const visited = new Set(); + let current: KnowledgeTreeNode | undefined = node; + while (current) { + if (visited.has(current.id) || current.isDeleted || current.access?.source !== "owner" + || !current.access.capabilities.canView || (current.resourceType === "notebook" && current.isPasswordProtected !== 0)) return false; + visited.add(current.id); + if (!current.parentId) return true; + current = byId.get(current.parentId); + } + return false; + }; + const combined = [...result.nodes,...provisional]; + result = {...result,nodes:[...result.nodes,...provisional.filter((node) => safeChain(node,combined))]}; const overrides = await repository.listPersonalTreeOverrides(); if (overrides.length) { - const safeChain = (node: KnowledgeTreeNode, nodes: KnowledgeTreeNode[]) => { - const byId = new Map(nodes.map((item) => [item.id,item])); - const visited = new Set(); - let current: KnowledgeTreeNode | undefined = node; - while (current) { - if (visited.has(current.id) || current.isDeleted || current.access?.source !== "owner" - || !current.access.capabilities.canView || (current.resourceType === "notebook" && current.isPasswordProtected !== 0)) return false; - visited.add(current.id); - if (!current.parentId) return true; - current = byId.get(current.parentId); - } - return false; - }; const nodes = result.nodes.map((node) => { const override = overrides.find((row) => row.id === node.id); return override ? { ...node,parentId:override.parentId as string | null,sortOrder:override.sortOrder as number } : node; @@ -227,7 +240,7 @@ export function installMobileLocalKnowledgeTreeBridge( } const drafts = await repository.listLocalTreeDrafts(); if (!drafts.length) { - if (!overrides.length) return result; + if (!overrides.length && !provisional.length) return result; const counts = new Map(); for (const node of result.nodes) if (node.parentId) counts.set(node.parentId,(counts.get(node.parentId)||0)+1); return {...result,nodes:applyKnowledgeTreeSort(result.nodes.map((node) => ({...node,childCount:counts.get(node.id)||0})))}; @@ -324,9 +337,17 @@ export function installMobileLocalKnowledgeTreeBridge( () => originals.listForWorkspace(workspaceId, includeDeleted), ); const createPersonal = async (workspaceId: string, input: Parameters[0]) => { - if (workspaceId !== "personal" || !db || (input.nodeType !== "note" && input.nodeType !== "markdown")) { + if (workspaceId !== "personal" || !db) { return originals.createForWorkspace(workspaceId, input); } + if (input.nodeType === "folder") { + try { return await repository.createPersonalTreeFolder(newLocalId(),input.title.trim() || "新建文件夹",input.parentId); } + catch (error) { + if ((error as {code?:string})?.code === "NATIVE_TREE_WRITE_UNAVAILABLE") return originals.createForWorkspace(workspaceId,input); + throw error; + } + } + if (input.nodeType !== "note" && input.nodeType !== "markdown") return originals.createForWorkspace(workspaceId,input); let notebookId: string | null = null; if (input.parentId) { const { nodes } = await target.listForWorkspace("personal"); diff --git a/frontend/src/lib/nativeLocalRepository.ts b/frontend/src/lib/nativeLocalRepository.ts index 7ea15982a..591d823b7 100644 --- a/frontend/src/lib/nativeLocalRepository.ts +++ b/frontend/src/lib/nativeLocalRepository.ts @@ -13,7 +13,7 @@ import { unsentLocalNoteKey } from "./nativeLocalNoteOrigin"; import { notifyMobileSyncStatusChanged } from "./mobileSyncStatus"; import { isEncryptedNoteFormat, validateEncryptedNoteWrite } from "./encryptedNotes/noteDocument"; import { ROOT_DOCUMENT_NOTEBOOK_PREFIX } from "./rootDocumentCreatePolicy"; -import { listNativePersonalTreeOverrides, writeNativePersonalTreeMoves, type NativeTreeMove } from "./nativePersonalTreeWrites"; +import { listNativePersonalTreeFolderDrafts, listNativePersonalTreeOverrides, writeNativePersonalTreeFolder, writeNativePersonalTreeMoves, type NativeTreeMove } from "./nativePersonalTreeWrites"; type EntityType = "notebook" | "note" | "tag" | "note_tag" | "favorite" | "attachment"; @@ -306,6 +306,22 @@ export class NativeLocalRepository implements LocalRepository { return sync ? listNativePersonalTreeOverrides(this.db,sync.profileId) : []; } + async createPersonalTreeFolder(id: string, title: string, parentId: string | null) { + const node = await this.db.transaction(async (tx) => { + const sync = await this.syncContext(tx); + if (!sync) throw Object.assign(new Error("个人目录同步订阅尚未确认"), {code:"NATIVE_TREE_WRITE_UNAVAILABLE"}); + return writeNativePersonalTreeFolder(tx,id,title,parentId,{...sync,userId:this.userId}, + (row) => this.enqueue(tx,"notebook",id,"upsert",row,undefined,"personal")); + }); + this.requestSyncCallback?.(); + notifyMobileSyncStatusChanged(); + return node; + } + + async listPersonalTreeFolderDrafts() { + return listNativePersonalTreeFolderDrafts(this.db,this.userId); + } + async exportWorkspaceScope(scopeKey:string):Promise> { return { format:"nowen-native-workspace-recovery-v1",scopeKey,exportedAt:now(), diff --git a/frontend/src/lib/nativePersonalTreeWrites.ts b/frontend/src/lib/nativePersonalTreeWrites.ts index 7f8da3fcf..8a93a6065 100644 --- a/frontend/src/lib/nativePersonalTreeWrites.ts +++ b/frontend/src/lib/nativePersonalTreeWrites.ts @@ -2,6 +2,7 @@ import type { KnowledgeTreeNode } from "./knowledgeTreeApi"; import type { NativeDatabase } from "./nativeDatabase"; import { applyNativePersonalTree, NATIVE_TREE_ENTITY_SET, nativeTreeSubscriptionKey } from "./nativeKnowledgeTreeSync"; import { newLocalId } from "./localRepository"; +import { ROOT_DOCUMENT_NOTEBOOK_PREFIX } from "./rootDocumentCreatePolicy"; export interface NativeTreeMove { id: string; parentId?: string | null; sortOrder?: number } export interface NativeTreeWriteContext { profileId: string; deviceId: string; userId: string } @@ -33,28 +34,19 @@ function unavailable(): never { throw Object.assign(new Error("个人目录订阅、权限或密码信息尚未确认,请使用在线操作"), { code: "NATIVE_TREE_WRITE_UNAVAILABLE" }); } -export async function syncNativeNotebookMoveParents(tx: NativeDatabase, ids: string[]): Promise { - for (const id of ids) { - const node = await readNativePersonalTreeNode(tx,id); - if (!node || node.resourceType !== "notebook") continue; - let parentId = node.parentId as string | null, container: string | null = null; - while (parentId) { - const parent = await readNativePersonalTreeNode(tx,parentId); - if (!parent) throw new Error("知识树父节点缺失"); - if (parent.resourceType === "notebook") { container = String(parent.resourceId); break; } - if (!parent.parentId && parent.resourceType === "note") { - container = (await tx.query<{notebookId:string}>("SELECT notebookId FROM notes WHERE scopeKey='personal' AND id=?",[parent.resourceId]))[0]?.notebookId || null; - } - parentId = parent.parentId as string | null; - } - await tx.run("UPDATE notebooks SET parentId=?,sortOrder=? WHERE scopeKey='personal' AND id=?",[container,node.sortOrder,node.resourceId]); - } +/** Creation provenance is local-only. It never confers access to a synced resource. */ +export async function listNativePersonalTreeFolderDrafts(db: NativeDatabase, userId: string): Promise { + const rows = await db.query(`SELECT t.id,t.userId,t.workspaceId,t.parentId,t.nodeType,t.resourceType, + t.resourceId,t.sortOrder,t.isDeleted,t.createdAt,t.updatedAt,n.name AS title,n.icon,n.isExpanded + FROM native_knowledge_tree_nodes t JOIN notebooks n ON n.scopeKey=t.scopeKey AND n.id=t.resourceId + JOIN native_runtime_meta m ON m.key='localTreeFolder:personal:' || n.id + WHERE t.scopeKey='personal' AND t.resourceType='notebook' AND t.userId=? AND n.userId=? AND n.isDeleted=0 AND t.isDeleted=0`, [userId,userId]); + return rows.map((row) => ({...row,scopeKey:`personal:${userId}`,childCount:0,isPasswordProtected:0, + access:{nodeId:row.id,rolePreset:"admin",source:"owner",sourceNodeId:null,capabilities:{canView:true,canComment:true, + canCreate:true,canEdit:true,canDelete:true,canMove:true,canDownload:true,canReshare:true,canManageMembers:true}}})); } -/** Caller owns the transaction. Cached owner ACL and complete unprotected ancestry are required. */ -export async function writeNativePersonalTreeMoves( - tx: NativeDatabase, items: NativeTreeMove[], context: NativeTreeWriteContext, -): Promise { +async function personalWriteProofs(tx: NativeDatabase, context: NativeTreeWriteContext) { const binding = (await tx.query<{value:string}>("SELECT value FROM native_runtime_meta WHERE key=?", [nativeTreeSubscriptionKey(context.profileId)]))[0]; if (binding?.value !== NATIVE_TREE_ENTITY_SET) unavailable(); const cached = (await tx.query<{value:string}>("SELECT value FROM native_runtime_meta WHERE key='knowledgeTreeSnapshot:v1:personal:0'"))[0]; @@ -64,25 +56,99 @@ export async function writeNativePersonalTreeMoves( if (!Array.isArray(parsed)) unavailable(); nodes = parsed; } catch { unavailable(); } + const drafts = await listNativePersonalTreeFolderDrafts(tx,context.userId); + for (const draft of drafts) { + if (!nodes.some((node) => node.id === draft.id) && (await tx.query(`SELECT id FROM sync_outbox + WHERE profileId=? AND scopeKey='personal' AND ((entityType='notebook' AND entityId=?) + OR (entityType='knowledge_tree_node' AND entityId=?)) LIMIT 1`,[context.profileId,draft.resourceId,draft.id])).length) nodes.push(draft); + } + const originalById = new Map(nodes.map((node) => [node.id,node])); const overrides = await listNativePersonalTreeOverrides(tx, context.profileId); nodes = nodes.map((node) => { const override = overrides.find((row) => row.id === node.id); return override ? { ...node, parentId: override.parentId as string | null, sortOrder: override.sortOrder as number } : node; }); const byId = new Map(nodes.map((node) => [node.id,node])); - const assertAncestry = (id: string): KnowledgeTreeNode => { - const first = byId.get(id); let node = first; + const stored = new Map((await tx.query<{id:string;parentId:string|null;isDeleted:number}>( + "SELECT id,parentId,isDeleted FROM native_knowledge_tree_nodes WHERE scopeKey='personal'")).map((node) => [node.id,node])); + const assertChain = (id: string, proof: Map, checkStored: boolean): void => { + let node = proof.get(id); const visited = new Set(); while (node) { if (visited.has(node.id) || node.userId !== context.userId || node.workspaceId !== null || node.isDeleted || node.access?.source !== "owner" || !node.access.capabilities.canView || (node.resourceType === "notebook" && node.isPasswordProtected !== 0)) unavailable(); + if (checkStored && (!stored.has(node.id) || stored.get(node.id)!.isDeleted || stored.get(node.id)!.parentId !== node.parentId)) unavailable(); visited.add(node.id); - if (!node.parentId) return first!; - node = byId.get(node.parentId); + if (!node.parentId) return; + node = proof.get(node.parentId); } unavailable(); }; + const assertAncestry = (id: string): KnowledgeTreeNode => { + assertChain(id,originalById,false); + assertChain(id,byId,true); + return byId.get(id)!; + }; + return assertAncestry; +} + +/** Business creation precedes its structure intent, in the caller's single transaction. */ +export async function writeNativePersonalTreeFolder(tx: NativeDatabase, id: string, title: string, parentId: string | null, + context: NativeTreeWriteContext, enqueueNotebook: (row: Record) => Promise): Promise { + const assertAncestry = await personalWriteProofs(tx,context); + if (parentId) { + const parent = assertAncestry(parentId), stored = await readNativePersonalTreeNode(tx,parentId); + if (!parent.access.capabilities.canCreate || !stored || stored.isDeleted || stored.parentId !== parent.parentId) unavailable(); + } + const timestamp = new Date().toISOString(); + const sortOrder = (await tx.query<{sortOrder:number}>(`SELECT COALESCE(MAX(sortOrder),-1)+1 AS sortOrder + FROM native_knowledge_tree_nodes WHERE scopeKey='personal' AND parentId IS ? AND isDeleted=0`,[parentId]))[0].sortOrder; + const desired = {id:`notebook:${id}`,userId:context.userId,workspaceId:null,parentId,nodeType:"folder",resourceType:"notebook", + resourceId:id,sortOrder,isDeleted:0,deletedAt:null,createdAt:timestamp,updatedAt:timestamp}; + const row = {id,scopeKey:"personal",workspaceId:null,userId:context.userId,parentId:null,name:title,icon:"📁",sortOrder, + isExpanded:1,isDeleted:0,deletedAt:null,createdAt:timestamp,updatedAt:timestamp}; + await tx.run(`INSERT INTO notebooks (id,scopeKey,workspaceId,userId,parentId,name,icon,sortOrder,isExpanded,isDeleted,deletedAt,createdAt,updatedAt) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,Object.values(row)); + await applyNativePersonalTree(tx,[{entityId:desired.id,payload:desired}],context.userId,false); + await syncNativeNotebookMoveParents(tx,[desired.id]); + const physical = (await tx.query<{parentId:string|null}>("SELECT parentId FROM notebooks WHERE scopeKey='personal' AND id=?",[id]))[0].parentId; + // Hidden root-document containers are deleted compatibility rows, so an + // ordinary notebook creation must start at root. Its tree intent attaches it + // to the document through the server's existing container-aware move service. + const initialParent = physical && !physical.startsWith(ROOT_DOCUMENT_NOTEBOOK_PREFIX) ? physical : null; + await enqueueNotebook({...row,parentId:initialParent}); + const base = {...desired,parentId:initialParent ? `notebook:${initialParent}` : null}; + await tx.run(`INSERT INTO sync_outbox (id,mutationId,profileId,deviceId,scopeKey,entityType,entityId,operation,payload,status,retryCount,createdAt) + VALUES (?,?,?,?,'personal','knowledge_tree_node',?,'upsert',?,'pending',0,?)`, + [newLocalId(),newLocalId(),context.profileId,context.deviceId,desired.id,JSON.stringify(nativeTreeMutationPayload(desired,base)),new Date().toISOString()]); + await tx.run("INSERT INTO native_runtime_meta (key,value,updatedAt) VALUES (?,'1',?)",[`localTreeFolder:personal:${id}`,timestamp]); + return (await listNativePersonalTreeFolderDrafts(tx,context.userId)).find((node) => node.id === desired.id)!; +} + +export async function syncNativeNotebookMoveParents(tx: NativeDatabase, ids: string[]): Promise { + for (const id of ids) { + const node = await readNativePersonalTreeNode(tx,id); + if (!node || node.resourceType !== "notebook") continue; + let parentId = node.parentId as string | null, container: string | null = null; + while (parentId) { + const parent = await readNativePersonalTreeNode(tx,parentId); + if (!parent) throw new Error("知识树父节点缺失"); + if (parent.resourceType === "notebook") { container = String(parent.resourceId); break; } + if (!parent.parentId && parent.resourceType === "note") { + container = (await tx.query<{notebookId:string}>("SELECT notebookId FROM notes WHERE scopeKey='personal' AND id=?",[parent.resourceId]))[0]?.notebookId || null; + } + parentId = parent.parentId as string | null; + } + await tx.run("UPDATE notebooks SET parentId=?,sortOrder=? WHERE scopeKey='personal' AND id=?",[container,node.sortOrder,node.resourceId]); + } +} + +/** Caller owns the transaction. Cached owner ACL and complete unprotected ancestry are required. */ +export async function writeNativePersonalTreeMoves( + tx: NativeDatabase, items: NativeTreeMove[], context: NativeTreeWriteContext, +): Promise { + const assertAncestry = await personalWriteProofs(tx,context); const entries = [], result: KnowledgeTreeNode[] = [], seen = new Set(); for (const item of items) { if (seen.has(item.id)) throw new Error("移动批次包含重复节点"); diff --git a/frontend/tests/android-sync/main.ts b/frontend/tests/android-sync/main.ts index 9f9eaca24..810021a89 100644 --- a/frontend/tests/android-sync/main.ts +++ b/frontend/tests/android-sync/main.ts @@ -67,6 +67,16 @@ async function initializeOnce(config: Config) { }, async schema() { return db.query("PRAGMA user_version"); }, async treeList() { return knowledgeTreeApi.listForWorkspace("personal"); }, + async createOfflineFolders() { + const root=await knowledgeTreeApi.create({parentId:null,nodeType:"folder",title:"Offline folder"}); + const child=await knowledgeTreeApi.create({parentId:root.id,nodeType:"folder",title:"Offline child"}); + const document=await knowledgeTreeApi.create({parentId:`note:${(await ids())[0]}`,nodeType:"folder",title:"Offline document child"}); + const note=await knowledgeTreeApi.create({parentId:child.id,nodeType:"markdown",title:"Offline nested note"}); + await api.updateNote(note.resourceId,{content:"folder-offline-body",contentText:"folder-offline-body"}); + return {folders:[root,child,document].map(({id,parentId})=>({id,parentId})),noteId:note.resourceId}; + }, + async noteStatus(id:string) {return {note:await api.getNote(id),receipt:await readNativeNoteSyncReceipt(db,profileId,id)};}, + async pendingMutations() {return db.query("SELECT mutationId,entityType,entityId FROM sync_outbox WHERE profileId=? ORDER BY createdAt,rowid",[profileId]);}, async treeMove(id: string, parentId: string | null, sortOrder: number) { return knowledgeTreeApi.move(id,{parentId,sortOrder}); }, async pendingTree() { return db.query("SELECT mutationId,status FROM sync_outbox WHERE profileId=? AND entityType='knowledge_tree_node'",[profileId]); }, async treeConflicts() { diff --git a/scripts/android-sync-acceptance.mjs b/scripts/android-sync-acceptance.mjs index 7c05b6bbe..cb8cf5f54 100644 --- a/scripts/android-sync-acceptance.mjs +++ b/scripts/android-sync-acceptance.mjs @@ -135,7 +135,44 @@ try { await native("resolveTreeConflict",conflict.id); await wait(async () => (await serverTree()).parentId===`note:${ids[0]}` && (await native("pendingTree")).length===0,"keep-local structure did not converge"); assert.equal((await native("treeConflicts")).length,0); + await native("treeList"); await native("stop"); + adb("reverse","--remove","tcp:47831"); + const created=await native("createOfflineFolders"); + const creationMutations=await native("pendingMutations"); + assert.equal(creationMutations.filter((row)=>row.entityType==="knowledge_tree_node").length,3); + const createdOfflineTree=await native("treeList"); + for(const item of created.folders) assert.equal(createdOfflineTree.nodes.find((node)=>node.id===item.id)?.parentId,item.parentId); + assert.equal((await native("noteStatus",created.noteId)).note.contentText,"folder-offline-body"); + await browser.close(); + adb("shell","am","force-stop","com.nowen.note.syncacceptance"); + adb("shell","am","start","-n","com.nowen.note.syncacceptance/com.nowen.note.MainActivity"); + await attach(); + assert.deepEqual(await native("pendingMutations"),creationMutations); + const restoredFolderTree=await native("treeList"); + for(const item of created.folders) { + assert.equal((await native("structure",item.id)).parentId,item.parentId); + assert.equal(restoredFolderTree.nodes.find((node)=>node.id===item.id)?.parentId,item.parentId); + } + assert.equal((await native("noteStatus",created.noteId)).note.contentText,"folder-offline-body"); + adb("reverse","tcp:47831","tcp:47831"); + const folderPushStarted=performance.now(); await native("start"); + await wait(async()=>{ + if((await native("pendingMutations")).length) return false; + for(const item of created.folders) { + const row=await (await fetch(`${base}/acceptance/tree/${encodeURIComponent(item.id)}`)).json(); + if(!row || row.parentId!==item.parentId) return false; + } + return (await serverNote(created.noteId))?.contentText==="folder-offline-body" + && (await native("noteStatus",created.noteId)).receipt.phase==="confirmed"; + },"offline folder resources, nesting and note did not automatically converge"); + const nativeFolderCreationMs=Math.round(performance.now()-folderPushStarted); + assert.equal((await native("treeConflicts")).length,0); + const acknowledgedFolderTree=await native("treeList"); + for(const item of created.folders) assert.equal(acknowledgedFolderTree.nodes.filter((node)=>node.id===item.id).length,1); const report = { device: serial, package: "com.nowen.note.syncacceptance", offlineImmediateRead: true, forceStopRecovery: true, + sourceCommit:execFileSync("git",["rev-parse","HEAD"],{encoding:"utf8"}).trim(), + nativeOfflineFolderCreate:true,nestedOfflineFolderCreate:true,offlineFolderUnderDocument:true,noteInsideOfflineFolder:true, + folderCreationMutationIdentitySurvivesRestart:true,folderCreationOfflineListRecovery:true,automaticFolderCreationPush:true,nativeFolderCreationMs, knowledgeTreeRootCreate: true, readWriteThroughProductionApiBridge: true, negotiatedPersonalTreePull:true,remoteFolderUnderDocument:true,remoteTreeMove:true,structureForceStopRecovery:true,nativeSchemaVersion:7, nativeOfflineTreeMoveAndSort:true,pendingTreeForceStopRecovery:true,automaticStructurePush:true,nativeConcurrentMoveKeepLocal:true, @@ -143,9 +180,9 @@ try { offlineNetworkIsolation: "adb reverse removed; HTTP server unreachable from Android", markdownAndRichText: true, missingNoticePoll: true, reconnect: true, httpPushToNativePull: stats(download), nativeCommitToServerAndAck: stats(upload), errors: await native("errors"), - boundary: "Loopback adb reverse; actual Android Native SQLite, production API Bridge personal tree move/sort, Outbox restart recovery, automatic Push and concurrent move keep-local via the Native conflict service. Full ConflictCenter GUI, EditorPane, protected/shared ACL, attachment bytes and full production JWT middleware are not accepted here." }; + boundary: "Loopback adb reverse; actual Android Native SQLite, production API Bridge personal folder creation/nesting/document parents and note creation inside the new folder, tree move/sort, Outbox restart recovery, automatic Push and concurrent move keep-local via the Native conflict service. Full ConflictCenter GUI, EditorPane, protected/shared ACL, attachment bytes and full production JWT middleware are not accepted here." }; fs.mkdirSync(new URL("../docs/test-results/", import.meta.url), { recursive: true }); - fs.writeFileSync(new URL("../docs/test-results/sync-v2-native-tree-writes-2026-10-11.json", import.meta.url), JSON.stringify(report, null, 2)); + fs.writeFileSync(new URL("../docs/test-results/sync-v2-native-folder-create-2026-10-11.json", import.meta.url), JSON.stringify(report, null, 2)); console.log(JSON.stringify(report, null, 2)); } finally { if (page) await native("stop").catch(() => undefined); From c892f5ea3efa530e60a5524ec256127943befd49 Mon Sep 17 00:00:00 2001 From: cropflre <767308865@qq.com> Date: Sun, 11 Oct 2026 20:32:10 +0800 Subject: [PATCH 11/13] docs(sync): record native offline folder creation and Android recovery acceptance --- CHANGELOG.md | 3 +- ...c-v2-native-folder-create-validation.en.md | 29 +++++++++++ ...sync-v2-native-folder-create-validation.md | 29 +++++++++++ ...ync-v2-native-tree-writes-validation.en.md | 2 + docs/sync-v2-native-tree-writes-validation.md | 2 + ...nc-v2-native-folder-create-2026-10-11.json | 51 +++++++++++++++++++ 6 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 docs/sync-v2-native-folder-create-validation.en.md create mode 100644 docs/sync-v2-native-folder-create-validation.md create mode 100644 docs/test-results/sync-v2-native-folder-create-2026-10-11.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 408840a7d..f449b9bd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,8 @@ ## 未发布 / Unreleased -- **sync**:个人普通文件夹的移动、批量移动与排序可先事务保存到 Native SQLite/Outbox,支持重启后上传和结构冲突选择;schema 7 无损升级队列,远端目录墓碑先保留本机位置与子正文。Persist ordinary personal folder moves and sorting atomically, replay after restart, and recover structural conflicts without cascading over retained child bodies. Document/mindmap/file moves and folder creation/trash/restore retain their existing online paths. 验收见 `docs/sync-v2-native-tree-writes-validation.md`。 +- **sync**:普通个人文件夹支持 Native 离线新建、嵌套创建和已有文档下创建,业务/结构 Outbox 同事务落库并依赖有序重放;新文件夹内可继续创建文档,重启与丢失 ACK 保留资源身份。Persist native personal folder creation/nesting and document parents atomically, replay ordered business/structure intents across restarts and lost ACKs, and create notes inside new folders. 验收见 `docs/sync-v2-native-folder-create-validation.md`。 +- **sync**:个人普通文件夹的移动、批量移动与排序可先事务保存到 Native SQLite/Outbox,支持重启后上传和结构冲突选择;schema 7 无损升级队列,远端目录墓碑先保留本机位置与子正文。Persist ordinary personal folder moves and sorting atomically, replay after restart, and recover structural conflicts without cascading over retained child bodies. Document/mindmap/file moves and folder trash/restore retain their existing online paths. 验收见 `docs/sync-v2-native-tree-writes-validation.md`。 - **sync**:Native 个人空间协商接收完整知识树结构,事务保存跨文档父子关系、移动与删除;升级重建订阅、首次 ACK 丢失保留基线。REST 树修改提交后唤醒 Pull,并修复移入根文档时隐藏容器校验失败。Receive negotiated personal tree structure on native clients, retain subscription intent across lost ACKs, notify after REST commits, and preserve hidden containers during root-document moves. - **android**:个人知识树根级和普通文件夹内的 Markdown/富文本可先保存到本机并进入现有 Sync V2;将已授权的个人远端正文持久化,保护重启和后续离线编辑。Persist personal native tree documents and authorized missing bodies before subsequent offline edits; full shared/protected tree synchronization remains incomplete. - **sync**:保护 Native 离线编辑、删除冲突和迟到 ACK;重试保留 mutationId,避免旧更新复活已删除笔记。Protect durable native edits, deletion conflicts and late ACKs without changing mutation identity. diff --git a/docs/sync-v2-native-folder-create-validation.en.md b/docs/sync-v2-native-folder-create-validation.en.md new file mode 100644 index 000000000..2a989f730 --- /dev/null +++ b/docs/sync-v2-native-folder-create-validation.en.md @@ -0,0 +1,29 @@ +# Native personal folder creation validation + +2026-10-11, `codex/native-knowledge-tree-offline`, continuing Draft PR [#831](https://github.com/cropflre/nowen-note/pull/831) into `release/v1.5.2`. Implementation `61ca1f03`, baseline `8119a34c`; [previous move/conflict validation](sync-v2-native-tree-writes-validation.en.md), [中文](sync-v2-native-folder-create-validation.md). + +## Implemented path + +Native clients with a completed personal tree subscription and cached access metadata can create ordinary folders offline at root, under an ordinary folder, or under an existing document. They can create nested folders and Markdown/rich-text notes inside the new folder. Stable resource identities, the notebook, tree projection, creation provenance and both Outbox intents commit in one SQLite transaction. A structural enqueue failure rolls everything back. This uses existing Sync V2 and schema 7 without a parallel protocol or data reset. + +The notebook mutation precedes its structural intent. Its base tuple matches the projection produced by server business creation. A root document's hidden compatibility container is soft-deleted and cannot serve as an ordinary legacy creation parent. Create the business resource at root first, then attach it to the document through the existing container-aware move service. Subsequent business renames preserve the existing hidden parent without rerunning the creation guard through UPSERT. New resources still pass the existing parent/scope guards. + +Only explicit local personal folder creation provides provisional provenance; the whole native graph never implies ACL. Writes require both the original cached path and the local-intent path to be complete, owner-visible and free of folder passwords, with the latter matching the native graph. A folder with unfinished creation intents may receive nested children. Live tree metadata takes over ACL/password authority; an ACKed folder omitted by a live tree is not reconstructed from provenance. Offline lists and restart recovery retain provenance awaiting that live handover. Missing subscription/proofs, shared and protected paths retain the online entry; storage failures propagate without issuing another creation. + +## Local validation + +- 322 related backend tests and 257 unique frontend tests across 39 files pass without skips. New cases cover nested resources and a second independent SQLite client, document parents, reopen/stable mutation identities, full rebuild, complete and structure-only lost ACKs, new writes during Push, 100-row dependency boundaries, atomic rollback, unbound/missing/foreign/protected ancestors, live proof handover and workspace fallback. +- All eight backend files mapped by the consistency contract separately pass 124 tests without skips; these overlap the first count. Contract gate: 12 invariants, 96 references, eight backend and 20 frontend files. +- Backend production build/build:tsc and frontend TypeScript/normal production build pass. Lint: 1447 historical errors, zero new errors. Frontend SQLite tests use Node 24; better-sqlite3 uses the repository Electron RunAsNode runtime. + +## Android acceptance + +Read-only Pixel_8_API_35 emulator, API 35 / WebView 124.0.6367.219, separate package `com.nowen.note.syncacceptance`. APK and server source: `61ca1f03`; APK SHA256 `23187c898752497b659f217aec3ee10fa74e044c56ef92e576997316118bc614`. Removing adb reverse proved Android could not reach the isolated server. The production API Bridge created a root folder, nested folder, folder under an existing document and Markdown inside the new folder, with immediate list/body reads. Force-stop/reopen preserved hierarchy, body and every mutationId. Reconnection automatically uploaded the batch; server hierarchy/body and native ACK converged, with empty Outbox and no tree conflicts. The single reconnect-to-confirmed batch sample was 1980 ms, not a folder-write percentile. + +The same APK reran folder move/sort, missed-notice polling, reconnection, native keep-local conflict recovery and bidirectional root Markdown/rich-text body/receipt tests. Thirty loopback body samples: HTTP Push→Native API P50/P95/P99 363/388/1067 ms; Native commit→server body+native ACK 338/381/406 ms. Remote folder create/move were single samples of 228 ms each. These include API/polling overhead and do not represent WAN or the full editor. Final lastError=null; Android crash buffer: zero lines. [Machine record](test-results/sync-v2-native-folder-create-2026-10-11.json). + +The connected PJZ110 real phone was not installed or accepted. The isolated server and read-only emulator are stopped. Normal frontend dist was rebuilt and excludes the test entry. Second-client reception is proven by the independent SQLite test above, not a second physical Android device. + +## Boundaries and next phase + +Keep the PR Draft. Ordinary personal folder creation and move/sort are enabled. Folder trash/restore and document/mindmap/file moves with body/attachment queues retain existing online paths. Shared/protected folders, full EditorPane/ConflictCenter GUI, real existing-user databases, WAN, attachment bytes, iOS/HarmonyOS, NAS and a production release are unproven. The recommended next phase is personal folder trash/restore with explicit child-body and pending-queue preservation. Do not close #829, merge or release. diff --git a/docs/sync-v2-native-folder-create-validation.md b/docs/sync-v2-native-folder-create-validation.md new file mode 100644 index 000000000..8919bff24 --- /dev/null +++ b/docs/sync-v2-native-folder-create-validation.md @@ -0,0 +1,29 @@ +# Native 个人文件夹离线创建验收 + +2026-10-11,`codex/native-knowledge-tree-offline`,继续 Draft PR [#831](https://github.com/cropflre/nowen-note/pull/831),目标 `release/v1.5.2`。代码 `61ca1f03`,基准 `8119a34c`;[上一批移动/冲突验收](sync-v2-native-tree-writes-validation.md)、[English](sync-v2-native-folder-create-validation.en.md)。 + +## 本批闭环 + +已完成个人完整树订阅并保存权限快照的 Native 客户端,可以在根级、普通文件夹或已有文档下离线创建普通文件夹,继续创建嵌套文件夹,以及在新文件夹内创建 Markdown/富文本。稳定资源 ID、业务资源、结构投影、创建来源和两个 Outbox 意图在同一个 SQLite 事务中保存;结构入队失败全部回滚。沿用现有 Sync V2 和 schema 7,不增加平行同步协议或清空旧数据。 + +业务 notebook 意图先于知识树意图入队,结构 tuple 的基准对应服务端业务创建生成的投影。根文档的隐藏兼容容器已软删除,不能作为旧版业务创建的父节点;先创建根级业务资源,再用服务端已有容器感知的移动服务挂到文档下。后续业务重命名保留既有隐藏容器父节点,避免 UPSERT 重跑新建守卫。普通新资源的父节点/空间守卫仍执行。 + +创建来源只记录本机新建的个人普通文件夹,不能从整个 Native 结构表推断 ACL。继续写入要求原始权限快照和本机意图覆盖后的完整父链均为 owner、可见、无文件夹密码,且后一条链与 Native 图一致;尚未上传完的新文件夹可以作为嵌套父节点。在线树接管真实 ACL/密码元数据,已 ACK 但被在线树省略的资源不会通过创建来源重新投影。离线列表和重启恢复保留尚未完成在线交接的创建来源。缺少订阅/证明、共享或密码路径走原在线入口;存储失败直接报错,不重发另一个在线创建。 + +## 本地验证 + +- 相关后端回归 322 项、前端去重回归 257 项(39 文件)通过,均无跳过。新增覆盖嵌套资源和第二个独立 SQLite 客户端、文档父节点、重启和 mutationId、全量重建、完整/仅结构 ACK 丢失、Push 中继续创建与移动、100 条分页依赖、整事务回滚、未订阅/缺父/异用户/密码父链拒绝、在线证明接管和工作区回退。 +- 契约矩阵全部 8 个后端映射文件另跑 124 项通过,无跳过,与上一项重叠;门禁 12 不变量、96 引用、8 后端文件、20 前端文件。 +- 后端生产 build/build:tsc、前端 TypeScript/正常生产 build 通过;lint 基线 1447 历史错误、0 新错误。Node 24 执行前端 SQLite 用例;better-sqlite3 使用项目 Electron RunAsNode。 + +## Android 验收 + +使用只读 Pixel_8_API_35 模拟器、API 35 / WebView 124.0.6367.219,独立包 `com.nowen.note.syncacceptance`。APK 与后端均为 `61ca1f03`,APK SHA256 `23187c898752497b659f217aec3ee10fa74e044c56ef92e576997316118bc614`。移除 adb reverse 确认 Android 无法访问隔离服务,然后通过生产 API Bridge 创建根文件夹、嵌套文件夹、已有文档下文件夹和新目录内 Markdown,立即读取列表与正文。强制停止重开后层级、正文与全部 mutationId 保留;恢复网络自动上传,服务端层级/正文与 Native ACK 一致,Outbox 清空、无结构冲突。创建批次从恢复同步到确认完成单次 1980 ms。该值不是文件夹写入延迟分位数。 + +同一 APK 重跑个人文件夹移动/排序、丢通知轮询、重连和 Native 冲突保留本机,以及根级 Markdown/富文本双向正文/回执。30 个 loopback 正文样本:HTTP Push→Native API 读取 P50/P95/P99 为 363/388/1067 ms;Native 提交→服务端正文+Native ACK 为 338/381/406 ms。远端目录创建/移动单次均为 228 ms。这些结果包含 API/轮询开销,不代表 WAN 或完整编辑器。最终同步 lastError=null,Android crash buffer 0 行。[机器记录](test-results/sync-v2-native-folder-create-2026-10-11.json)。 + +连接的 PJZ110 真实手机未安装或验收。隔离后端和只读模拟器均已停止,正常前端 dist 已重建且不含验收入口。第二设备接收由上述独立 SQLite 客户端证明;这里未测试第二台实体 Android 设备。 + +## 当前边界与下一阶段 + +继续保留 Draft。普通个人文件夹创建和移动/排序已启用;文件夹删除/恢复,以及文档/脑图/文件移动与正文、附件队列的联动仍走既有在线路径。没有证明共享/密码目录、完整手机编辑器和冲突中心 GUI、真实旧用户库、WAN、附件 bytes、iOS/HarmonyOS、NAS 或正式发布。建议下一阶段接入个人文件夹回收站/恢复,重点保护子节点正文和待发送队列;不关闭 #829、不合并或发布。 diff --git a/docs/sync-v2-native-tree-writes-validation.en.md b/docs/sync-v2-native-tree-writes-validation.en.md index 7111aa7a8..f6fdc0545 100644 --- a/docs/sync-v2-native-tree-writes-validation.en.md +++ b/docs/sync-v2-native-tree-writes-validation.en.md @@ -1,5 +1,7 @@ # Native personal folder writes and structural conflict recovery +Personal offline folder creation is now implemented; see [current creation validation](sync-v2-native-folder-create-validation.en.md). The earlier move/conflict evidence below retains its original acceptance scope. + 2026-10-11, continuing Draft [PR #831](https://github.com/cropflre/nowen-note/pull/831) on `codex/native-knowledge-tree-offline` into `release/v1.5.2`, baseline `24594972`. [Chinese evidence](sync-v2-native-tree-writes-validation.md), [previous reception batch](sync-v2-native-tree-pull-validation.en.md). Subscribed Native clients can move, batch-move and sort ordinary owned personal folders under the root, a folder or a document. Structure, compatible notebook parent/order and Outbox commit atomically; failed queue inserts or graph validation roll back every local move. Complete cached owner ancestry without folder passwords is required. Structural rows provide no ACL proof. Unavailable authorization retains the existing online path; storage failure never starts a second remote write. diff --git a/docs/sync-v2-native-tree-writes-validation.md b/docs/sync-v2-native-tree-writes-validation.md index 97b1f7845..3f8a17156 100644 --- a/docs/sync-v2-native-tree-writes-validation.md +++ b/docs/sync-v2-native-tree-writes-validation.md @@ -1,5 +1,7 @@ # Native 个人文件夹写入与结构冲突恢复 +后续个人文件夹离线创建已接入;当前状态与新增证据见[创建验收](sync-v2-native-folder-create-validation.md)。下文保留本批移动/冲突的验收范围。 + 2026-10-11,`codex/native-knowledge-tree-offline`,继续 Draft PR [#831](https://github.com/cropflre/nowen-note/pull/831),目标 `release/v1.5.2`。本批基准 `24594972`;[上一批接收验收](sync-v2-native-tree-pull-validation.md)、[English](sync-v2-native-tree-writes-validation.en.md)。 ## 已实现的闭环 diff --git a/docs/test-results/sync-v2-native-folder-create-2026-10-11.json b/docs/test-results/sync-v2-native-folder-create-2026-10-11.json new file mode 100644 index 000000000..39ca91804 --- /dev/null +++ b/docs/test-results/sync-v2-native-folder-create-2026-10-11.json @@ -0,0 +1,51 @@ +{ + "device": "emulator-5580", + "package": "com.nowen.note.syncacceptance", + "offlineImmediateRead": true, + "forceStopRecovery": true, + "sourceCommit": "61ca1f03d4fd057e9cc2dc818a599a0febb33423", + "nativeOfflineFolderCreate": true, + "nestedOfflineFolderCreate": true, + "offlineFolderUnderDocument": true, + "noteInsideOfflineFolder": true, + "folderCreationMutationIdentitySurvivesRestart": true, + "folderCreationOfflineListRecovery": true, + "automaticFolderCreationPush": true, + "nativeFolderCreationMs": 1980, + "knowledgeTreeRootCreate": true, + "readWriteThroughProductionApiBridge": true, + "negotiatedPersonalTreePull": true, + "remoteFolderUnderDocument": true, + "remoteTreeMove": true, + "structureForceStopRecovery": true, + "nativeSchemaVersion": 7, + "nativeOfflineTreeMoveAndSort": true, + "pendingTreeForceStopRecovery": true, + "automaticStructurePush": true, + "nativeConcurrentMoveKeepLocal": true, + "missingTreeNoticePoll": true, + "remoteFolderCreateMs": 228, + "remoteTreeMoveMs": 228, + "offlineNetworkIsolation": "adb reverse removed; HTTP server unreachable from Android", + "markdownAndRichText": true, + "missingNoticePoll": true, + "reconnect": true, + "httpPushToNativePull": { + "samples": 30, + "p50Ms": 363, + "p95Ms": 388, + "p99Ms": 1067 + }, + "nativeCommitToServerAndAck": { + "samples": 30, + "p50Ms": 338, + "p95Ms": 381, + "p99Ms": 406 + }, + "errors": [ + { + "lastError": null + } + ], + "boundary": "Loopback adb reverse; actual Android Native SQLite, production API Bridge personal folder creation/nesting/document parents and note creation inside the new folder, tree move/sort, Outbox restart recovery, automatic Push and concurrent move keep-local via the Native conflict service. Full ConflictCenter GUI, EditorPane, protected/shared ACL, attachment bytes and full production JWT middleware are not accepted here." +} \ No newline at end of file From c3d9b9ffb130fb849088aeaf522c814e0d362981 Mon Sep 17 00:00:00 2001 From: cropflre <767308865@qq.com> Date: Sun, 11 Oct 2026 20:42:30 +0800 Subject: [PATCH 12/13] fix(sync): retain pending folder creates across snapshot rebuilds --- .../tests/sync-v2-mobile-multidevice.test.ts | 20 +++++++++++++++++++ .../data-consistency-contract.matrix.json | 1 + frontend/src/lib/mobileSyncEngine.ts | 9 ++++++++- frontend/src/lib/nativePersonalTreeWrites.ts | 4 +++- 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/backend/tests/sync-v2-mobile-multidevice.test.ts b/backend/tests/sync-v2-mobile-multidevice.test.ts index e095403ad..867df82b6 100644 --- a/backend/tests/sync-v2-mobile-multidevice.test.ts +++ b/backend/tests/sync-v2-mobile-multidevice.test.ts @@ -206,6 +206,26 @@ test("creation dependencies remain ordered when folder mutations span the 100-ro assert.equal(serverDb.prepare("SELECT parentId FROM knowledge_tree_nodes WHERE id=?").get(`notebook:${ids.at(-1)}`).parentId,`notebook:${ids.at(-2)}`); }); +test("a folder created during the final Snapshot page remains a creation intent rather than a remote tombstone",async()=>{ + const {c}=await movableTree("folder-create-during-snapshot"), id=randomUUID(), child=randomUUID(), note=randomUUID(); + await c.db.run("UPDATE sync_state SET lastSequence=0 WHERE profileId=?",[c.profileId]); + afterSnapshot=async(response)=>{ + const page=await response.clone().json() as {nextCursor:string|null}; + if(!page.nextCursor) { + afterSnapshot=undefined; + await c.repository.createPersonalTreeFolder(id,"During Snapshot",null); + await c.repository.createPersonalTreeFolder(child,"Nested during Snapshot",`notebook:${id}`); + await c.repository.createPersonalTreeNote({id:note,contentFormat:"markdown",content:"During Snapshot body"},child); + } + return response; + }; + try {await c.sync();} finally {afterSnapshot=undefined;} + assert.equal((await conflicts(c)).length,0); assert.equal((await pending(c)).length,0); + assert.equal(serverDb.prepare("SELECT parentId FROM knowledge_tree_nodes WHERE id=?").get(`notebook:${id}`).parentId,null); + assert.equal(serverDb.prepare("SELECT parentId FROM knowledge_tree_nodes WHERE id=?").get(`notebook:${child}`).parentId,`notebook:${id}`); + assert.equal((await c.repository.notes.get(note))?.content,"During Snapshot body"); +}); + test("offline folder creation rolls back the business row, structure, origin and both queues on a structural enqueue failure", async () => { const {c}=await movableTree("folder-create-rollback"), id=randomUUID(); await c.db.run("CREATE TRIGGER reject_folder_create BEFORE INSERT ON sync_outbox WHEN NEW.entityType='knowledge_tree_node' BEGIN SELECT RAISE(ABORT,'Folder Outbox failure'); END"); diff --git a/docs/architecture/data-consistency-contract.matrix.json b/docs/architecture/data-consistency-contract.matrix.json index e919dd3da..2bd2f53d1 100644 --- a/docs/architecture/data-consistency-contract.matrix.json +++ b/docs/architecture/data-consistency-contract.matrix.json @@ -323,6 +323,7 @@ { "file": "backend/tests/sync-v2-mobile-multidevice.test.ts", "case": "folder creation fails closed for unbound" }, { "file": "backend/tests/sync-v2-mobile-multidevice.test.ts", "case": "a lost structural creation ACK retains the local folder" }, { "file": "backend/tests/sync-v2-mobile-multidevice.test.ts", "case": "creation dependencies remain ordered" }, + { "file": "backend/tests/sync-v2-mobile-multidevice.test.ts", "case": "a folder created during the final Snapshot page" }, { "file": "frontend/src/lib/__tests__/mobileNativeTreeWritesBridge.test.ts", "case": "creates personal folders through the transaction" }, { "file": "frontend/src/lib/__tests__/mobileNativeTreeWritesBridge.test.ts", "case": "restores reverse-ordered provisional nested folders" }, { "file": "frontend/src/lib/__tests__/mobileNativeTreeWritesBridge.test.ts", "case": "does not grant provisional owner access to an ACKed folder" }, diff --git a/frontend/src/lib/mobileSyncEngine.ts b/frontend/src/lib/mobileSyncEngine.ts index 0eb676dd9..e0cb36f26 100644 --- a/frontend/src/lib/mobileSyncEngine.ts +++ b/frontend/src/lib/mobileSyncEngine.ts @@ -529,7 +529,14 @@ export class MobileSyncEngine { if (protectedTree) { await applyNativePersonalTree(tx,local.map((payload) => ({entityId:String(payload.id),payload})),this.options.userId,true); const seen = new Set(tree.map((entry) => entry.entityId)); - await this.applyTreeEntries(tx,[...tree,...local.filter((row) => !seen.has(String(row.id))) + // A creation committed while pages were in flight cannot appear in + // that snapshot. Only its original still-pending business mutation + // proves this; a later update must not mask a remote tombstone. + const creating = new Set((await tx.query<{id:string}>(`SELECT 'notebook:' || o.entityId AS id FROM sync_outbox o + JOIN native_runtime_meta m ON m.key='localTreeFolder:personal:' || o.entityId AND m.value=o.mutationId + WHERE o.profileId=? AND o.scopeKey='personal' AND o.entityType='notebook' AND o.operation='upsert'`, + [this.options.profileId])).map((row) => row.id)); + await this.applyTreeEntries(tx,[...tree,...local.filter((row) => !seen.has(String(row.id)) && !creating.has(String(row.id))) .map((row) => ({entityId:String(row.id),payload:{__delete:true}}))]); } // Persist the negotiated graph and subscription intent together. A crash diff --git a/frontend/src/lib/nativePersonalTreeWrites.ts b/frontend/src/lib/nativePersonalTreeWrites.ts index 8a93a6065..b15a48ef1 100644 --- a/frontend/src/lib/nativePersonalTreeWrites.ts +++ b/frontend/src/lib/nativePersonalTreeWrites.ts @@ -122,7 +122,9 @@ export async function writeNativePersonalTreeFolder(tx: NativeDatabase, id: stri await tx.run(`INSERT INTO sync_outbox (id,mutationId,profileId,deviceId,scopeKey,entityType,entityId,operation,payload,status,retryCount,createdAt) VALUES (?,?,?,?,'personal','knowledge_tree_node',?,'upsert',?,'pending',0,?)`, [newLocalId(),newLocalId(),context.profileId,context.deviceId,desired.id,JSON.stringify(nativeTreeMutationPayload(desired,base)),new Date().toISOString()]); - await tx.run("INSERT INTO native_runtime_meta (key,value,updatedAt) VALUES (?,'1',?)",[`localTreeFolder:personal:${id}`,timestamp]); + const creation = (await tx.query<{mutationId:string}>(`SELECT mutationId FROM sync_outbox WHERE profileId=? AND scopeKey='personal' + AND entityType='notebook' AND entityId=? ORDER BY createdAt,rowid LIMIT 1`,[context.profileId,id]))[0]; + await tx.run("INSERT INTO native_runtime_meta (key,value,updatedAt) VALUES (?,?,?)",[`localTreeFolder:personal:${id}`,creation.mutationId,timestamp]); return (await listNativePersonalTreeFolderDrafts(tx,context.userId)).find((node) => node.id === desired.id)!; } From c260199c183d918081caecc63da2fd43c9f4cc85 Mon Sep 17 00:00:00 2001 From: cropflre <767308865@qq.com> Date: Sun, 11 Oct 2026 20:47:11 +0800 Subject: [PATCH 13/13] docs(sync): update folder creation snapshot and final Android evidence --- CHANGELOG.md | 2 +- ...c-v2-native-folder-create-validation.en.md | 15 ++++++++------ ...sync-v2-native-folder-create-validation.md | 15 ++++++++------ ...nc-v2-native-folder-create-2026-10-11.json | 20 +++++++++---------- 4 files changed, 29 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f449b9bd9..723f24ede 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ ## 未发布 / Unreleased -- **sync**:普通个人文件夹支持 Native 离线新建、嵌套创建和已有文档下创建,业务/结构 Outbox 同事务落库并依赖有序重放;新文件夹内可继续创建文档,重启与丢失 ACK 保留资源身份。Persist native personal folder creation/nesting and document parents atomically, replay ordered business/structure intents across restarts and lost ACKs, and create notes inside new folders. 验收见 `docs/sync-v2-native-folder-create-validation.md`。 +- **sync**:普通个人文件夹支持 Native 离线新建、嵌套创建和已有文档下创建,业务/结构 Outbox 同事务落库并依赖有序重放;新文件夹内可继续创建文档,重启与丢失 ACK 保留资源身份,快照重建期间新建目录不会误记为远端删除。Persist native personal folder creation/nesting and document parents atomically, replay ordered business/structure intents across restarts and lost ACKs, and preserve creations made during Snapshot rebuilding. 验收见 `docs/sync-v2-native-folder-create-validation.md`。 - **sync**:个人普通文件夹的移动、批量移动与排序可先事务保存到 Native SQLite/Outbox,支持重启后上传和结构冲突选择;schema 7 无损升级队列,远端目录墓碑先保留本机位置与子正文。Persist ordinary personal folder moves and sorting atomically, replay after restart, and recover structural conflicts without cascading over retained child bodies. Document/mindmap/file moves and folder trash/restore retain their existing online paths. 验收见 `docs/sync-v2-native-tree-writes-validation.md`。 - **sync**:Native 个人空间协商接收完整知识树结构,事务保存跨文档父子关系、移动与删除;升级重建订阅、首次 ACK 丢失保留基线。REST 树修改提交后唤醒 Pull,并修复移入根文档时隐藏容器校验失败。Receive negotiated personal tree structure on native clients, retain subscription intent across lost ACKs, notify after REST commits, and preserve hidden containers during root-document moves. - **android**:个人知识树根级和普通文件夹内的 Markdown/富文本可先保存到本机并进入现有 Sync V2;将已授权的个人远端正文持久化,保护重启和后续离线编辑。Persist personal native tree documents and authorized missing bodies before subsequent offline edits; full shared/protected tree synchronization remains incomplete. diff --git a/docs/sync-v2-native-folder-create-validation.en.md b/docs/sync-v2-native-folder-create-validation.en.md index 2a989f730..d5b71f4e5 100644 --- a/docs/sync-v2-native-folder-create-validation.en.md +++ b/docs/sync-v2-native-folder-create-validation.en.md @@ -1,6 +1,6 @@ # Native personal folder creation validation -2026-10-11, `codex/native-knowledge-tree-offline`, continuing Draft PR [#831](https://github.com/cropflre/nowen-note/pull/831) into `release/v1.5.2`. Implementation `61ca1f03`, baseline `8119a34c`; [previous move/conflict validation](sync-v2-native-tree-writes-validation.en.md), [中文](sync-v2-native-folder-create-validation.md). +2026-10-11, `codex/native-knowledge-tree-offline`, continuing Draft PR [#831](https://github.com/cropflre/nowen-note/pull/831) into `release/v1.5.2`. Creation `61ca1f03`, Snapshot race fix `c3d9b9ff`, baseline `8119a34c`; [previous move/conflict validation](sync-v2-native-tree-writes-validation.en.md), [中文](sync-v2-native-folder-create-validation.md). ## Implemented path @@ -12,17 +12,20 @@ Only explicit local personal folder creation provides provisional provenance; th ## Local validation -- 322 related backend tests and 257 unique frontend tests across 39 files pass without skips. New cases cover nested resources and a second independent SQLite client, document parents, reopen/stable mutation identities, full rebuild, complete and structure-only lost ACKs, new writes during Push, 100-row dependency boundaries, atomic rollback, unbound/missing/foreign/protected ancestors, live proof handover and workspace fallback. -- All eight backend files mapped by the consistency contract separately pass 124 tests without skips; these overlap the first count. Contract gate: 12 invariants, 96 references, eight backend and 20 frontend files. +Folders, nested folders and bodies created while the final Snapshot page returns cannot appear in that snapshot. Provenance records the original business mutationId. Only that original still-pending creation exempts snapshot absence from a synthesized remote tombstone; subsequent business updates cannot use old provenance to mask deletion. A real multi-database regression reproduced the false conflict and verified the fix. + +- Final related backend tests: 323; unique frontend tests: 257 across 39 files. All pass without skips. New cases cover nested resources and a second independent SQLite client, document parents, reopen/stable mutation identities, creation during full rebuild, complete and structure-only lost ACKs, new writes during Push, 100-row dependency boundaries, atomic rollback, unbound/missing/foreign/protected ancestors, live proof handover and workspace fallback. +- All eight backend files mapped by the consistency contract separately pass 125 tests without skips; these overlap the first count. Contract gate: 12 invariants, 97 references, eight backend and 20 frontend files. - Backend production build/build:tsc and frontend TypeScript/normal production build pass. Lint: 1447 historical errors, zero new errors. Frontend SQLite tests use Node 24; better-sqlite3 uses the repository Electron RunAsNode runtime. +- Separately inspected [I18n CI](https://github.com/cropflre/nowen-note/actions/runs/38139639518): one of 39 tests fails on the same 16 encryptedConversion keys as [pre-change 8119a34c](https://github.com/cropflre/nowen-note/actions/runs/38137818852). Production index merges the translation module; the existing test fixture omits it. The relevant components and i18n directory are unchanged from the release baseline. Local execution reproduces the failure; this suite is excluded from the 257 count. Keep Draft and do not claim all CI passes. ## Android acceptance -Read-only Pixel_8_API_35 emulator, API 35 / WebView 124.0.6367.219, separate package `com.nowen.note.syncacceptance`. APK and server source: `61ca1f03`; APK SHA256 `23187c898752497b659f217aec3ee10fa74e044c56ef92e576997316118bc614`. Removing adb reverse proved Android could not reach the isolated server. The production API Bridge created a root folder, nested folder, folder under an existing document and Markdown inside the new folder, with immediate list/body reads. Force-stop/reopen preserved hierarchy, body and every mutationId. Reconnection automatically uploaded the batch; server hierarchy/body and native ACK converged, with empty Outbox and no tree conflicts. The single reconnect-to-confirmed batch sample was 1980 ms, not a folder-write percentile. +Read-only Pixel_8_API_35 emulator, API 35 / WebView 124.0.6367.219, separate package `com.nowen.note.syncacceptance`. Final APK and server source: `c3d9b9ff`; APK SHA256 `80897d65fd8007a92fda9b7bcc7ff3cb9a9b146aa414600c3c11c502581e6713`. Removing adb reverse proved Android could not reach the isolated server. The production API Bridge created a root folder, nested folder, folder under an existing document and Markdown inside the new folder, with immediate list/body reads. Force-stop/reopen preserved hierarchy, body and every mutationId. Reconnection automatically uploaded the batch; server hierarchy/body and native ACK converged, with empty Outbox and no tree conflicts. The single reconnect-to-confirmed batch sample was 2002 ms, not a folder-write percentile. -The same APK reran folder move/sort, missed-notice polling, reconnection, native keep-local conflict recovery and bidirectional root Markdown/rich-text body/receipt tests. Thirty loopback body samples: HTTP Push→Native API P50/P95/P99 363/388/1067 ms; Native commit→server body+native ACK 338/381/406 ms. Remote folder create/move were single samples of 228 ms each. These include API/polling overhead and do not represent WAN or the full editor. Final lastError=null; Android crash buffer: zero lines. [Machine record](test-results/sync-v2-native-folder-create-2026-10-11.json). +The same APK reran folder move/sort, missed-notice polling, reconnection, native keep-local conflict recovery and bidirectional root Markdown/rich-text body/receipt tests. Thirty loopback body samples: HTTP Push→Native API P50/P95/P99 350/378/1161 ms; Native commit→server body+native ACK 339/362/369 ms. Remote folder create/move were single samples of 66/217 ms. These include API/polling overhead and do not represent WAN or the full editor. Final lastError=null; Android crash buffer: zero lines. [Machine record](test-results/sync-v2-native-folder-create-2026-10-11.json). -The connected PJZ110 real phone was not installed or accepted. The isolated server and read-only emulator are stopped. Normal frontend dist was rebuilt and excludes the test entry. Second-client reception is proven by the independent SQLite test above, not a second physical Android device. +The connected PJZ110 real phone was not installed or accepted. The isolated server and read-only emulator are stopped. Normal frontend dist and Android packaging assets are restored and exclude the test entry. Second-client reception is proven by the independent SQLite test above, not a second physical Android device. The final-page Snapshot creation race is specifically proven by multi-database tests; the Android driver did not inject that within-page race. ## Boundaries and next phase diff --git a/docs/sync-v2-native-folder-create-validation.md b/docs/sync-v2-native-folder-create-validation.md index 8919bff24..03e14c4cf 100644 --- a/docs/sync-v2-native-folder-create-validation.md +++ b/docs/sync-v2-native-folder-create-validation.md @@ -1,6 +1,6 @@ # Native 个人文件夹离线创建验收 -2026-10-11,`codex/native-knowledge-tree-offline`,继续 Draft PR [#831](https://github.com/cropflre/nowen-note/pull/831),目标 `release/v1.5.2`。代码 `61ca1f03`,基准 `8119a34c`;[上一批移动/冲突验收](sync-v2-native-tree-writes-validation.md)、[English](sync-v2-native-folder-create-validation.en.md)。 +2026-10-11,`codex/native-knowledge-tree-offline`,继续 Draft PR [#831](https://github.com/cropflre/nowen-note/pull/831),目标 `release/v1.5.2`。创建代码 `61ca1f03`,Snapshot 竞态修复 `c3d9b9ff`,基准 `8119a34c`;[上一批移动/冲突验收](sync-v2-native-tree-writes-validation.md)、[English](sync-v2-native-folder-create-validation.en.md)。 ## 本批闭环 @@ -12,17 +12,20 @@ ## 本地验证 -- 相关后端回归 322 项、前端去重回归 257 项(39 文件)通过,均无跳过。新增覆盖嵌套资源和第二个独立 SQLite 客户端、文档父节点、重启和 mutationId、全量重建、完整/仅结构 ACK 丢失、Push 中继续创建与移动、100 条分页依赖、整事务回滚、未订阅/缺父/异用户/密码父链拒绝、在线证明接管和工作区回退。 -- 契约矩阵全部 8 个后端映射文件另跑 124 项通过,无跳过,与上一项重叠;门禁 12 不变量、96 引用、8 后端文件、20 前端文件。 +全量 Snapshot 的最后一页返回期间新建文件夹/嵌套目录及正文,原快照不会包含它们。创建来源保存原始业务 mutationId;只有该原始创建意图仍在队列时,快照缺席才保留为未上传创建,不合成为远端墓碑。后续业务更新不能借旧来源掩盖远端删除。该竞态先复现结构冲突,再通过真实多库回归验证修复。 + +- 最终相关后端回归 323 项、前端去重回归 257 项(39 文件)通过,均无跳过。新增覆盖嵌套资源和第二个独立 SQLite 客户端、文档父节点、重启和 mutationId、全量重建期间创建、完整/仅结构 ACK 丢失、Push 中继续创建与移动、100 条分页依赖、整事务回滚、未订阅/缺父/异用户/密码父链拒绝、在线证明接管和工作区回退。 +- 契约矩阵全部 8 个后端映射文件另跑 125 项通过,无跳过,与上一项重叠;门禁 12 不变量、97 引用、8 后端文件、20 前端文件。 - 后端生产 build/build:tsc、前端 TypeScript/正常生产 build 通过;lint 基线 1447 历史错误、0 新错误。Node 24 执行前端 SQLite 用例;better-sqlite3 使用项目 Electron RunAsNode。 +- 另核查 [I18n CI](https://github.com/cropflre/nowen-note/actions/runs/38139639518):覆盖套件 39 项中 1 项失败,与[改动前的 8119a34c](https://github.com/cropflre/nowen-note/actions/runs/38137818852)同样缺少 16 个 encryptedConversion 键。生产 index 已合并该翻译模块,既有测试装配漏合并;相关组件与 i18n 目录相对 release 基准均未改动。本地复现同一失败,该套件不计入上面的 257 项;保留 Draft,不宣称全部 CI 通过。 ## Android 验收 -使用只读 Pixel_8_API_35 模拟器、API 35 / WebView 124.0.6367.219,独立包 `com.nowen.note.syncacceptance`。APK 与后端均为 `61ca1f03`,APK SHA256 `23187c898752497b659f217aec3ee10fa74e044c56ef92e576997316118bc614`。移除 adb reverse 确认 Android 无法访问隔离服务,然后通过生产 API Bridge 创建根文件夹、嵌套文件夹、已有文档下文件夹和新目录内 Markdown,立即读取列表与正文。强制停止重开后层级、正文与全部 mutationId 保留;恢复网络自动上传,服务端层级/正文与 Native ACK 一致,Outbox 清空、无结构冲突。创建批次从恢复同步到确认完成单次 1980 ms。该值不是文件夹写入延迟分位数。 +使用只读 Pixel_8_API_35 模拟器、API 35 / WebView 124.0.6367.219,独立包 `com.nowen.note.syncacceptance`。最终 APK 与后端均为 `c3d9b9ff`,APK SHA256 `80897d65fd8007a92fda9b7bcc7ff3cb9a9b146aa414600c3c11c502581e6713`。移除 adb reverse 确认 Android 无法访问隔离服务,然后通过生产 API Bridge 创建根文件夹、嵌套文件夹、已有文档下文件夹和新目录内 Markdown,立即读取列表与正文。强制停止重开后层级、正文与全部 mutationId 保留;恢复网络自动上传,服务端层级/正文与 Native ACK 一致,Outbox 清空、无结构冲突。创建批次从恢复同步到确认完成单次 2002 ms。该值不是文件夹写入延迟分位数。 -同一 APK 重跑个人文件夹移动/排序、丢通知轮询、重连和 Native 冲突保留本机,以及根级 Markdown/富文本双向正文/回执。30 个 loopback 正文样本:HTTP Push→Native API 读取 P50/P95/P99 为 363/388/1067 ms;Native 提交→服务端正文+Native ACK 为 338/381/406 ms。远端目录创建/移动单次均为 228 ms。这些结果包含 API/轮询开销,不代表 WAN 或完整编辑器。最终同步 lastError=null,Android crash buffer 0 行。[机器记录](test-results/sync-v2-native-folder-create-2026-10-11.json)。 +同一 APK 重跑个人文件夹移动/排序、丢通知轮询、重连和 Native 冲突保留本机,以及根级 Markdown/富文本双向正文/回执。30 个 loopback 正文样本:HTTP Push→Native API 读取 P50/P95/P99 为 350/378/1161 ms;Native 提交→服务端正文+Native ACK 为 339/362/369 ms。远端目录创建/移动单次为 66/217 ms。这些结果包含 API/轮询开销,不代表 WAN 或完整编辑器。最终同步 lastError=null,Android crash buffer 0 行。[机器记录](test-results/sync-v2-native-folder-create-2026-10-11.json)。 -连接的 PJZ110 真实手机未安装或验收。隔离后端和只读模拟器均已停止,正常前端 dist 已重建且不含验收入口。第二设备接收由上述独立 SQLite 客户端证明;这里未测试第二台实体 Android 设备。 +连接的 PJZ110 真实手机未安装或验收。隔离后端和只读模拟器均已停止,正常前端 dist 与 Android 打包 assets 已恢复且不含验收入口。第二设备接收由上述独立 SQLite 客户端证明;这里未测试第二台实体 Android 设备。Snapshot 最后一页并发创建的特定竞态由多库测试证明,Android 驱动未注入该页内竞态。 ## 当前边界与下一阶段 diff --git a/docs/test-results/sync-v2-native-folder-create-2026-10-11.json b/docs/test-results/sync-v2-native-folder-create-2026-10-11.json index 39ca91804..ab412a87f 100644 --- a/docs/test-results/sync-v2-native-folder-create-2026-10-11.json +++ b/docs/test-results/sync-v2-native-folder-create-2026-10-11.json @@ -3,7 +3,7 @@ "package": "com.nowen.note.syncacceptance", "offlineImmediateRead": true, "forceStopRecovery": true, - "sourceCommit": "61ca1f03d4fd057e9cc2dc818a599a0febb33423", + "sourceCommit": "c3d9b9ffb130fb849088aeaf522c814e0d362981", "nativeOfflineFolderCreate": true, "nestedOfflineFolderCreate": true, "offlineFolderUnderDocument": true, @@ -11,7 +11,7 @@ "folderCreationMutationIdentitySurvivesRestart": true, "folderCreationOfflineListRecovery": true, "automaticFolderCreationPush": true, - "nativeFolderCreationMs": 1980, + "nativeFolderCreationMs": 2002, "knowledgeTreeRootCreate": true, "readWriteThroughProductionApiBridge": true, "negotiatedPersonalTreePull": true, @@ -24,23 +24,23 @@ "automaticStructurePush": true, "nativeConcurrentMoveKeepLocal": true, "missingTreeNoticePoll": true, - "remoteFolderCreateMs": 228, - "remoteTreeMoveMs": 228, + "remoteFolderCreateMs": 66, + "remoteTreeMoveMs": 217, "offlineNetworkIsolation": "adb reverse removed; HTTP server unreachable from Android", "markdownAndRichText": true, "missingNoticePoll": true, "reconnect": true, "httpPushToNativePull": { "samples": 30, - "p50Ms": 363, - "p95Ms": 388, - "p99Ms": 1067 + "p50Ms": 350, + "p95Ms": 378, + "p99Ms": 1161 }, "nativeCommitToServerAndAck": { "samples": 30, - "p50Ms": 338, - "p95Ms": 381, - "p99Ms": 406 + "p50Ms": 339, + "p95Ms": 362, + "p99Ms": 369 }, "errors": [ {