Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion .github/workflows/android-local-first-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,21 @@ on:
- "frontend/src/lib/mobileLocal*.ts"
- "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"
- "frontend/src/lib/__tests__/mobileSyncUnexpectedFailure.test.ts"
- "frontend/src/lib/__tests__/mobileSyncEndpoint.test.ts"
- "frontend/src/App.tsx"
- "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:
Expand All @@ -27,12 +36,21 @@ on:
- "frontend/src/lib/mobileLocal*.ts"
- "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"
- "frontend/src/lib/__tests__/mobileSyncUnexpectedFailure.test.ts"
- "frontend/src/lib/__tests__/mobileSyncEndpoint.test.ts"
- "frontend/src/App.tsx"
- "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:
Expand All @@ -50,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

Expand All @@ -68,11 +86,17 @@ 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
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/__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
12 changes: 12 additions & 0 deletions .github/workflows/data-consistency-contract-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ on:
- "backend/src/routes/notes*"
- "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"
Expand All @@ -27,6 +30,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*"
Expand All @@ -51,6 +57,9 @@ on:
- "backend/src/routes/notes*"
- "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"
Expand All @@ -65,6 +74,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*"
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@

## 未发布 / 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 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.
- **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.
Expand Down
20 changes: 20 additions & 0 deletions backend/src/routes/knowledge-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string>();
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") || "";
}
Expand Down
13 changes: 12 additions & 1 deletion backend/src/services/knowledgeTreeRootDocuments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions backend/src/sync/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 应用层。
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions backend/src/sync/knowledgeTreeStructure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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')
Expand Down
25 changes: 25 additions & 0 deletions backend/tests/knowledge-tree-root-documents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
13 changes: 13 additions & 0 deletions backend/tests/sync-v2-android-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -31,7 +32,19 @@ 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.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",
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<string, unknown> | undefined;
if (!note) return c.json({ error: "NOT_FOUND" }, 404);
Expand Down
Loading
Loading