diff --git a/apps/api/src/memo-service.ts b/apps/api/src/memo-service.ts index 8d86961c..8b81c1a7 100644 --- a/apps/api/src/memo-service.ts +++ b/apps/api/src/memo-service.ts @@ -21,6 +21,7 @@ import { auditStatement } from "./audit"; import type { AppContext, AuditActor, AuthContext, Bindings } from "./api-context"; import { AppError } from "./app-error"; import { createId, isoNow, parseJsonArray } from "./entity-utils"; +import { workspaceInboxId } from "./notebook-service"; import { sha256 } from "./hash-utils"; import { getRequiredString } from "./mcp-json-rpc"; import { @@ -596,7 +597,13 @@ export const restoreMemosRecord = async ( const needsInbox = rows.results.some((row) => !activeNotebookIds.has(row.notebook_id)); const inbox = needsInbox - ? await db.prepare(`SELECT id FROM notebooks WHERE workspace_id = ? AND slug = 'inbox' AND is_deleted = 0 LIMIT 1`).bind(workspaceId).first<{ id: string }>() + ? await db.prepare( + `SELECT id FROM notebooks + WHERE workspace_id = ? AND is_deleted = 0 + AND (id = ? OR id = 'nb_inbox' OR slug = 'inbox') + ORDER BY CASE WHEN id = ? THEN 0 WHEN id = 'nb_inbox' THEN 1 ELSE 2 END + LIMIT 1`, + ).bind(workspaceId, workspaceInboxId(workspaceId), workspaceInboxId(workspaceId)).first<{ id: string }>() : null; if (needsInbox && !inbox) { throw new AppError("restore_notebook_missing", "Original notebooks were deleted and the default inbox is unavailable.", 409); diff --git a/apps/api/src/notebook-service.test.mjs b/apps/api/src/notebook-service.test.mjs new file mode 100644 index 00000000..5dc1316e --- /dev/null +++ b/apps/api/src/notebook-service.test.mjs @@ -0,0 +1,106 @@ +import { describe, expect, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { globSync, readFileSync } from "node:fs"; +import { createSelfHostedStorageAdapter } from "./self-hosted-storage-adapter.ts"; +import { deleteNotebookRecord, isInboxNotebook, updateNotebookRecord } from "./notebook-service.ts"; +import { workspaceInboxId } from "./workspace-provisioning.ts"; + +const actor = { actorType: "user", actorId: "usr_1" }; + +const createFixture = () => { + const sqlite = new Database(":memory:"); + sqlite.exec(` + CREATE TABLE notebooks ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, parent_id TEXT, name TEXT NOT NULL, + slug TEXT, icon TEXT, color TEXT, sort_order INTEGER NOT NULL DEFAULT 0, + is_deleted INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, + deleted_at TEXT + ); + CREATE TABLE memos ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, notebook_id TEXT NOT NULL, title TEXT, + is_deleted INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL + ); + CREATE TABLE audit_events ( + id TEXT PRIMARY KEY, actor_type TEXT NOT NULL, actor_id TEXT, action TEXT NOT NULL, + entity_type TEXT NOT NULL, entity_id TEXT NOT NULL, metadata_json TEXT NOT NULL, created_at TEXT NOT NULL + ); + INSERT INTO notebooks (id, workspace_id, name, slug, is_deleted, created_at, updated_at) VALUES + ('ws_1_inbox', 'ws_1', '等待分类', 'inbox', 0, '2026-09-10T00:00:00.000Z', '2026-09-10T00:00:00.000Z'), + ('nb_notes', 'ws_1', 'Notes', 'notes', 0, '2026-09-10T00:00:00.000Z', '2026-09-10T00:00:00.000Z'); + `); + return { sqlite, db: createSelfHostedStorageAdapter(sqlite, "/tmp/edgeever-notebook-service-test-unused").db }; +}; + +describe("notebook identity", () => { + test("recognizes legacy, workspace-scoped, and slug inbox identities", () => { + expect(isInboxNotebook({ id: "nb_inbox", slug: "notes" }, "ws_1")).toBe(true); + expect(isInboxNotebook({ id: workspaceInboxId("ws_1"), slug: "shou-ji-xiang" }, "ws_1")).toBe(true); + expect(isInboxNotebook({ id: "nb_other", slug: "inbox" }, "ws_1")).toBe(true); + expect(isInboxNotebook({ id: "nb_notes", slug: "notes" }, "ws_1")).toBe(false); + }); + + test("keeps the inbox slug when the default notebook is renamed", async () => { + const fixture = createFixture(); + try { + const notebook = await updateNotebookRecord( + fixture.db, + "ws_1", + "ws_1_inbox", + { name: "收集箱" }, + actor, + ); + expect(notebook).toMatchObject({ id: "ws_1_inbox", name: "收集箱", slug: "inbox" }); + } finally { + fixture.sqlite.close(); + } + }); + + test("migration 0046 restores renamed, deleted, and missing inbox identities", () => { + const sqlite = new Database(":memory:"); + try { + sqlite.exec("PRAGMA foreign_keys = ON"); + for (const file of globSync("migrations/*.sql").sort().filter((file) => file.split("/").at(-1) < "0046")) { + sqlite.exec(readFileSync(file, "utf8")); + } + sqlite.exec(` + UPDATE notebooks SET name = '收集箱', slug = 'shou-ji-xiang' WHERE id = 'nb_inbox'; + INSERT INTO workspaces (id, name) VALUES ('ws_empty', 'Empty'); + INSERT INTO workspaces (id, name) VALUES ('ws_trashed', 'Trashed inbox'); + INSERT INTO notebooks (id, workspace_id, name, slug, is_deleted, deleted_at) + VALUES ('ws_trashed_inbox', 'ws_trashed', '等待分类', 'inbox', 1, '2026-09-10T00:00:00.000Z'); + `); + sqlite.exec(readFileSync("migrations/0046_restore_workspace_inbox_identity.sql", "utf8")); + expect(sqlite.query("SELECT name, slug, is_deleted FROM notebooks WHERE id = 'nb_inbox'").get()).toEqual({ + name: "收集箱", + slug: "inbox", + is_deleted: 0, + }); + expect(sqlite.query("SELECT slug, is_deleted FROM notebooks WHERE id = 'ws_trashed_inbox'").get()).toEqual({ + slug: "inbox", + is_deleted: 0, + }); + expect(sqlite.query("SELECT name, slug FROM notebooks WHERE id = 'ws_empty_inbox'").get()).toEqual({ + name: "等待分类", + slug: "inbox", + }); + expect(sqlite.query("PRAGMA foreign_key_check").all()).toEqual([]); + } finally { + sqlite.close(); + } + }); + + test("rejects deleting a workspace inbox after it was renamed", async () => { + const fixture = createFixture(); + try { + await updateNotebookRecord(fixture.db, "ws_1", "ws_1_inbox", { name: "收集箱" }, actor); + await expect(deleteNotebookRecord(fixture.db, "ws_1", "ws_1_inbox", actor)).rejects.toMatchObject({ + code: "bad_request", + }); + expect( + fixture.sqlite.query("SELECT is_deleted, slug FROM notebooks WHERE id = 'ws_1_inbox'").get(), + ).toEqual({ is_deleted: 0, slug: "inbox" }); + } finally { + fixture.sqlite.close(); + } + }); +}); diff --git a/apps/api/src/notebook-service.ts b/apps/api/src/notebook-service.ts index aef91b01..ce1e88c5 100644 --- a/apps/api/src/notebook-service.ts +++ b/apps/api/src/notebook-service.ts @@ -4,6 +4,13 @@ import { AppError } from "./app-error"; import { audit, auditStatement } from "./audit"; import { createId, isoNow, slugify } from "./entity-utils"; import type { DatabaseAdapter } from "./storage-contract"; +import { isInboxNotebook, workspaceInboxId } from "./workspace-provisioning"; + +export { isInboxNotebook, workspaceInboxId } from "./workspace-provisioning"; + +/** SQL identity for the workspace inbox: stable id first, slug as a fallback. */ +export const inboxNotebookIdentitySql = (alias: string) => + `(${alias}.id = ${alias}.workspace_id || '_inbox' OR ${alias}.id = 'nb_inbox' OR ${alias}.slug = 'inbox')`; export type NotebookRow = { id: string; @@ -195,6 +202,7 @@ export const updateNotebookRecord = async ( const nextName = input.name ?? current.name; const nextParentId = input.parentId === undefined ? current.parentId : input.parentId; const nextSortOrder = input.sortOrder ?? current.sortOrder; + const nextSlug = isInboxNotebook(current, workspaceId) ? "inbox" : slugify(nextName); const now = isoNow(); if (nextParentId === id) { @@ -220,7 +228,7 @@ export const updateNotebookRecord = async ( SET name = ?, slug = ?, parent_id = ?, sort_order = ?, updated_at = ? WHERE id = ? AND workspace_id = ? AND is_deleted = 0` ) - .bind(nextName, slugify(nextName), nextParentId ?? null, nextSortOrder, now, id, workspaceId), + .bind(nextName, nextSlug, nextParentId ?? null, nextSortOrder, now, id, workspaceId), auditStatement(db, actor.actorType, actor.actorId, "notebook.update", "notebook", id, input), ]); @@ -241,7 +249,7 @@ export const deleteNotebookRecord = async ( ) => { const current = await getNotebook(db, workspaceId, id); if (!current) throw new AppError("not_found", "Notebook not found", 404); - if (id === "nb_inbox" || current.slug === "inbox") { + if (isInboxNotebook(current, workspaceId)) { throw new AppError("bad_request", "等待分类不能删除。", 400); } @@ -266,8 +274,9 @@ export const deleteNotebookRecord = async ( await db.prepare( `UPDATE notebooks SET is_deleted = 1, deleted_at = ?, updated_at = ? - WHERE id = ? AND workspace_id = ? AND slug <> 'inbox'` - ).bind(now, now, id, workspaceId).run(); + WHERE id = ? AND workspace_id = ? AND slug <> 'inbox' + AND id <> 'nb_inbox' AND id <> ?` + ).bind(now, now, id, workspaceId, workspaceInboxId(workspaceId)).run(); await audit(db, actor.actorType, actor.actorId, "notebook.delete", "notebook", id, {}); }; diff --git a/apps/api/src/sync-routes.test.mjs b/apps/api/src/sync-routes.test.mjs index b3c893a8..ca1c5af7 100644 --- a/apps/api/src/sync-routes.test.mjs +++ b/apps/api/src/sync-routes.test.mjs @@ -112,6 +112,67 @@ describe("sync route contracts", () => { } }); + test("remaps dangling memos onto a renamed workspace inbox", async () => { + const sqlite = new Database(":memory:"); + sqlite.exec(` + CREATE TABLE workspaces (id TEXT PRIMARY KEY, created_at TEXT NOT NULL); + CREATE TABLE notebooks ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, parent_id TEXT, name TEXT NOT NULL, + slug TEXT, icon TEXT, color TEXT, sort_order INTEGER NOT NULL DEFAULT 0, + is_deleted INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL + ); + CREATE TABLE memos ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, notebook_id TEXT NOT NULL, title TEXT, + excerpt TEXT NOT NULL DEFAULT '', tags_json TEXT NOT NULL DEFAULT '[]', + is_pinned INTEGER NOT NULL DEFAULT 0, is_archived INTEGER NOT NULL DEFAULT 0, + is_deleted INTEGER NOT NULL DEFAULT 0, source_memo_ids TEXT NOT NULL DEFAULT '[]', + merge_source_count INTEGER NOT NULL DEFAULT 0, merged_into_memo_id TEXT, + created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted_at TEXT + ); + CREATE TABLE memo_contents ( + memo_id TEXT PRIMARY KEY, revision INTEGER NOT NULL DEFAULT 0, content_json TEXT NOT NULL, + content_markdown TEXT NOT NULL DEFAULT '', content_text TEXT NOT NULL DEFAULT '', content_hash TEXT NOT NULL + ); + CREATE TABLE mobile_sync_changes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, workspace_id TEXT NOT NULL, entity_type TEXT NOT NULL, + entity_id TEXT NOT NULL, operation TEXT NOT NULL + ); + INSERT INTO workspaces VALUES ('ws_1', '2026-09-10T00:00:00.000Z'); + INSERT INTO notebooks (id, workspace_id, name, slug, is_deleted, created_at, updated_at) VALUES + ('ws_1_inbox', 'ws_1', '收集箱', 'shou-ji-xiang', 0, '2026-09-10T00:00:00.000Z', '2026-09-10T00:00:00.000Z'); + INSERT INTO memos ( + id, workspace_id, notebook_id, title, is_deleted, created_at, updated_at, deleted_at + ) VALUES ( + 'memo_trashed', 'ws_1', 'nb_deleted', 'Trashed', 1, + '2026-09-10T00:00:00.000Z', '2026-09-10T00:00:00.000Z', '2026-09-10T00:00:00.000Z' + ); + INSERT INTO memo_contents VALUES ('memo_trashed', 1, '{"type":"doc"}', '', '', 'hash'); + `); + const storage = createSelfHostedStorageAdapter(sqlite, "/tmp/edgeever-sync-renamed-inbox-unused"); + const app = new Hono(); + app.use("/api/v1/*", async (context, next) => { + context.set("auth", agentAuth); + await next(); + }); + registerSyncRoutes(app, { + clampNumber: (value, min, max) => Math.min(Math.max(value, min), max), + mapMemoDetail: (row) => ({ + id: row.id, + notebookId: row.notebook_id, + title: row.title, + isDeleted: row.is_deleted === 1, + }), + }); + try { + const response = await app.request("/api/v1/sync/bootstrap?limit=200", {}, { storage }); + expect(response.status).toBe(200); + const payload = await response.json(); + expect(payload.memos[0].notebookId).toBe("ws_1_inbox"); + } finally { + sqlite.close(); + } + }); + test("normalizes dangling references in incremental changes", async () => { const fixture = createSyncIntegrationFixture(); try { diff --git a/apps/api/src/sync-routes.ts b/apps/api/src/sync-routes.ts index c6a550a5..7bf65e3d 100644 --- a/apps/api/src/sync-routes.ts +++ b/apps/api/src/sync-routes.ts @@ -1,7 +1,7 @@ import type { MemoDetail } from "@edgeever/shared"; import type { Hono } from "hono"; import type { AppEnv } from "./api-context"; -import { mapNotebook, type NotebookRow } from "./notebook-service"; +import { inboxNotebookIdentitySql, mapNotebook, type NotebookRow } from "./notebook-service"; import { getWorkspaceId, requireScopes } from "./request-auth"; type MobileSyncChangeRow = { @@ -60,8 +60,13 @@ const syncMemoNotebookIdSql = `CASE ELSE COALESCE(( SELECT inbox.id FROM notebooks inbox WHERE inbox.workspace_id = m.workspace_id - AND inbox.slug = 'inbox' AND inbox.is_deleted = 0 + AND ${inboxNotebookIdentitySql("inbox")} + ORDER BY CASE + WHEN inbox.id = inbox.workspace_id || '_inbox' THEN 0 + WHEN inbox.id = 'nb_inbox' THEN 1 + ELSE 2 + END LIMIT 1 ), m.notebook_id) END`; diff --git a/apps/api/src/workspace-provisioning.test.mjs b/apps/api/src/workspace-provisioning.test.mjs index 695561ba..b00f08e8 100644 --- a/apps/api/src/workspace-provisioning.test.mjs +++ b/apps/api/src/workspace-provisioning.test.mjs @@ -1,7 +1,10 @@ import { describe, expect, test } from "bun:test"; import { + createDefaultNotebookRows, createWorkspaceDefaultSeedStatements, ensureUserWorkspace, + isInboxNotebook, + workspaceInboxId, } from "./workspace-provisioning.ts"; const statement = (sql, calls) => ({ @@ -20,6 +23,15 @@ const statement = (sql, calls) => ({ }); describe("workspace provisioning", () => { + test("uses a stable workspace-scoped inbox id", () => { + expect(workspaceInboxId("ws_1")).toBe("ws_1_inbox"); + expect(createDefaultNotebookRows("ws_1")[0]).toMatchObject({ + id: "ws_1_inbox", + slug: "inbox", + }); + expect(isInboxNotebook({ id: "ws_1_inbox", slug: "shou-ji-xiang" }, "ws_1")).toBe(true); + }); + test("does not restore defaults while resolving an existing workspace", async () => { const calls = []; let batchCount = 0; diff --git a/apps/api/src/workspace-provisioning.ts b/apps/api/src/workspace-provisioning.ts index 7c6e1801..08faf26d 100644 --- a/apps/api/src/workspace-provisioning.ts +++ b/apps/api/src/workspace-provisioning.ts @@ -14,6 +14,17 @@ import type { } from "./storage-contract"; export const DEFAULT_WORKSPACE_ID = "ws_default"; +export const LEGACY_INBOX_NOTEBOOK_ID = "nb_inbox"; + +export const workspaceInboxId = (workspaceId: string) => `${workspaceId}_inbox`; + +export const isInboxNotebookId = (id: string, workspaceId: string) => + id === LEGACY_INBOX_NOTEBOOK_ID || id === workspaceInboxId(workspaceId); + +export const isInboxNotebook = ( + notebook: { id: string; slug?: string | null }, + workspaceId: string, +) => isInboxNotebookId(notebook.id, workspaceId) || notebook.slug === "inbox"; export type UserWorkspace = { workspaceId: string; @@ -29,7 +40,7 @@ export type DefaultNotebookRow = { }; export const createDefaultNotebookRows = (workspaceId: string): DefaultNotebookRow[] => [ - { id: `${workspaceId}_inbox`, name: "等待分类", slug: "inbox", color: "#0f766e", sortOrder: 10 }, + { id: workspaceInboxId(workspaceId), name: "等待分类", slug: "inbox", color: "#0f766e", sortOrder: 10 }, { id: `${workspaceId}_projects`, name: "工作项目", slug: "work-projects", color: "#2563eb", sortOrder: 20 }, { id: `${workspaceId}_learning`, name: "学习资料", slug: "learning-resources", color: "#7c3aed", sortOrder: 30 }, { id: `${workspaceId}_creative`, name: "灵感创作", slug: "creative-ideas", color: "#db2777", sortOrder: 40 }, diff --git a/apps/web/src/lib/desktop-sync.test.mjs b/apps/web/src/lib/desktop-sync.test.mjs index aa702a9b..a524eb69 100644 --- a/apps/web/src/lib/desktop-sync.test.mjs +++ b/apps/web/src/lib/desktop-sync.test.mjs @@ -11,6 +11,7 @@ const { mergeSyncedMemos, normalizeDesktopMemoPayload, orderBootstrapNotebooks, + orderDesktopSyncChanges, resolveDesktopMemoSyncBase, rewriteStagedResource, shouldAttemptDesktopRecoveryPull, @@ -102,6 +103,30 @@ describe("desktop bootstrap sync", () => { "grandchild", ]); }); + + test("applies notebook upserts before memos in an incremental change page", () => { + const memo = { entityType: "memo", entityId: "memo-1", operation: "upsert", notebook: null }; + const child = { + entityType: "notebook", + entityId: "child", + operation: "upsert", + notebook: { id: "child", parentId: "parent" }, + }; + const parent = { + entityType: "notebook", + entityId: "parent", + operation: "upsert", + notebook: { id: "parent", parentId: null }, + }; + const deleted = { entityType: "notebook", entityId: "gone", operation: "delete", notebook: null }; + + expect(orderDesktopSyncChanges([memo, child, deleted, parent]).map((change) => change.entityId)).toEqual([ + "parent", + "child", + "gone", + "memo-1", + ]); + }); }); describe("desktop memo sync base", () => { diff --git a/apps/web/src/lib/desktop-sync.ts b/apps/web/src/lib/desktop-sync.ts index df157a77..9ec79cda 100644 --- a/apps/web/src/lib/desktop-sync.ts +++ b/apps/web/src/lib/desktop-sync.ts @@ -495,9 +495,9 @@ export const shouldAttemptDesktopRecoveryPull = ( && phase !== "finalize_resource_remap" && shouldPullDesktopChanges(status, online); -export const orderBootstrapNotebooks = (notebooks: SyncBootstrapResponse["notebooks"]) => { +export const orderBootstrapNotebooks = (notebooks: T[]) => { const remaining = new Map(notebooks.map((notebook) => [notebook.id, notebook])); - const ordered: SyncBootstrapResponse["notebooks"] = []; + const ordered: T[] = []; while (remaining.size > 0) { let added = 0; @@ -516,6 +516,32 @@ export const orderBootstrapNotebooks = (notebooks: SyncBootstrapResponse["notebo return ordered; }; +export const orderDesktopSyncChanges = (changes: T[]) => { + const notebookUpserts: T[] = []; + const notebookDeletes: T[] = []; + const rest: T[] = []; + for (const change of changes) { + if (change.entityType !== "notebook") rest.push(change); + else if (change.operation === "delete") notebookDeletes.push(change); + else notebookUpserts.push(change); + } + const byId = new Map(notebookUpserts.map((change) => [change.entityId, change])); + const orderedUpserts = orderBootstrapNotebooks( + notebookUpserts.map((change) => change.notebook ?? { id: change.entityId, parentId: null }), + ).flatMap((notebook) => { + const change = byId.get(notebook.id); + return change ? [change] : []; + }); + const orderedIds = new Set(orderedUpserts.map((change) => change.entityId)); + const leftoverUpserts = notebookUpserts.filter((change) => !orderedIds.has(change.entityId)); + return [...orderedUpserts, ...leftoverUpserts, ...notebookDeletes, ...rest]; +}; + const bootstrapDesktopMirror = async (reset: boolean) => { await request("sync.bootstrap.prepare", reset ? { reset: true } : {}); let afterId: string | null = null; @@ -549,7 +575,7 @@ const pullRemoteChanges = async () => { return; } while (response.changes.length > 0) { - await request("sync.apply", { changes: response.changes }); + await request("sync.apply", { changes: orderDesktopSyncChanges(response.changes) }); cursor = response.cursor; if (!response.hasMore) break; response = await api.syncChanges({ cursor, limit: 200 }); diff --git a/crates/desktop-sidecar/src/catalog.rs b/crates/desktop-sidecar/src/catalog.rs index 2b7b6f7b..ef82420d 100644 --- a/crates/desktop-sidecar/src/catalog.rs +++ b/crates/desktop-sidecar/src/catalog.rs @@ -5,6 +5,10 @@ use std::time::{SystemTime, UNIX_EPOCH}; use crate::memo::memo_value; use crate::{enqueue_change, markdown_doc, now_id, string_param, tags_from_json}; +pub(crate) fn is_inbox_notebook(id: &str, slug: Option<&str>) -> bool { + slug == Some("inbox") || id == "nb_inbox" || id.ends_with("_inbox") +} + pub(crate) fn list_notebooks(database: &Connection) -> Result { let mut statement = database.prepare( "SELECT n.id, n.parent_id, n.name, n.slug, n.icon, n.color, n.sort_order, n.created_at, n.updated_at, @@ -65,7 +69,19 @@ pub(crate) fn create_notebook(database: &Connection, params: &Value) -> Result Result { let notebook_id = string_param(params, "notebookId")?; if let Some(name) = params.get("name").and_then(Value::as_str) { - database.execute("UPDATE notebooks SET name = ?1, slug = ?2, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?3 AND is_deleted = 0", rusqlite::params![name, name.to_lowercase().replace(' ', "-"), notebook_id]).map_err(|e| e.to_string())?; + let current_slug: Option = database + .query_row( + "SELECT slug FROM notebooks WHERE id = ?1", + [¬ebook_id], + |row| row.get(0), + ) + .map_err(|e| e.to_string())?; + let slug = if is_inbox_notebook(¬ebook_id, current_slug.as_deref()) { + "inbox".to_owned() + } else { + name.to_lowercase().replace(' ', "-") + }; + database.execute("UPDATE notebooks SET name = ?1, slug = ?2, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?3 AND is_deleted = 0", rusqlite::params![name, slug, notebook_id]).map_err(|e| e.to_string())?; } if params.get("parentId").is_some() { database.execute("UPDATE notebooks SET parent_id = ?1, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?2 AND is_deleted = 0", rusqlite::params![params.get("parentId").and_then(Value::as_str), notebook_id]).map_err(|e| e.to_string())?; @@ -97,7 +113,7 @@ pub(crate) fn delete_notebook(database: &Connection, params: &Value) -> Result 0 || memo_count > 0 { return Err("notebook_not_empty".to_owned()); } - database.execute("UPDATE notebooks SET is_deleted = 1, deleted_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?1 AND slug <> 'inbox'", [¬ebook_id]).map_err(|e| e.to_string())?; + database.execute("UPDATE notebooks SET is_deleted = 1, deleted_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?1 AND slug <> 'inbox' AND id <> 'nb_inbox' AND id NOT GLOB '*_inbox'", [¬ebook_id]).map_err(|e| e.to_string())?; enqueue_change( database, "notebook.delete", diff --git a/crates/desktop-sidecar/src/main.rs b/crates/desktop-sidecar/src/main.rs index 2687f33c..822ffd2b 100644 --- a/crates/desktop-sidecar/src/main.rs +++ b/crates/desktop-sidecar/src/main.rs @@ -471,4 +471,299 @@ mod tests { "nb_inbox" ); } + + #[test] + fn sync_apply_recreates_inbox_and_orders_notebooks_before_memos() { + let database = Connection::open_in_memory().unwrap(); + database + .execute_batch( + "PRAGMA foreign_keys = ON; + CREATE TABLE notebooks ( + id TEXT PRIMARY KEY, + parent_id TEXT REFERENCES notebooks(id), + name TEXT NOT NULL, + slug TEXT, + icon TEXT, + color TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + is_deleted INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT 'now', + updated_at TEXT NOT NULL DEFAULT 'now', + deleted_at TEXT + ); + CREATE TABLE memos ( + id TEXT PRIMARY KEY, + notebook_id TEXT NOT NULL REFERENCES notebooks(id), + title TEXT, + excerpt TEXT NOT NULL, + tags_json TEXT NOT NULL, + is_pinned INTEGER NOT NULL, + is_archived INTEGER NOT NULL, + is_deleted INTEGER NOT NULL, + source_memo_ids TEXT NOT NULL, + merge_source_count INTEGER NOT NULL, + merged_into_memo_id TEXT REFERENCES memos(id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT + ); + CREATE TABLE memo_contents ( + memo_id TEXT PRIMARY KEY REFERENCES memos(id) ON DELETE CASCADE, + content_json TEXT NOT NULL, + content_markdown TEXT NOT NULL, + content_text TEXT NOT NULL, + content_hash TEXT NOT NULL, + revision INTEGER NOT NULL, + updated_at TEXT NOT NULL DEFAULT 'now' + );", + ) + .unwrap(); + + let memo = |id: &str, notebook_id: &str| { + json!({ + "id": id, + "notebookId": notebook_id, + "title": id, + "excerpt": "", + "tags": [], + "isPinned": false, + "isArchived": false, + "isDeleted": true, + "sourceMemoIds": [], + "mergeSourceCount": 0, + "mergedIntoMemoId": null, + "createdAt": "2026-09-10T00:00:00.000Z", + "updatedAt": "2026-09-10T00:00:00.000Z", + "deletedAt": "2026-09-10T00:00:00.000Z", + "contentJson": { "type": "doc", "content": [] }, + "contentMarkdown": "", + "contentText": "", + "contentHash": id, + "revision": 0 + }) + }; + + apply_sync_changes( + &database, + &json!({ "changes": [{ + "entityType": "memo", + "operation": "upsert", + "entityId": "orphan-memo", + "memo": memo("orphan-memo", "missing-notebook") + }] }), + ) + .unwrap(); + assert_eq!( + database + .query_row( + "SELECT notebook_id FROM memos WHERE id = 'orphan-memo'", + [], + |row| row.get::<_, String>(0), + ) + .unwrap(), + "nb_inbox" + ); + + apply_sync_changes( + &database, + &json!({ "changes": [ + { + "entityType": "memo", + "operation": "upsert", + "entityId": "page-memo", + "memo": memo("page-memo", "still-missing") + }, + { + "entityType": "notebook", + "operation": "upsert", + "entityId": "child", + "notebook": { + "name": "Child", + "slug": "child", + "parentId": "parent", + "createdAt": "2026-09-10T00:00:00.000Z", + "updatedAt": "2026-09-10T00:00:00.000Z" + } + }, + { + "entityType": "notebook", + "operation": "upsert", + "entityId": "ws_1_inbox", + "notebook": { + "name": "收集箱", + "slug": "shou-ji-xiang", + "parentId": null, + "createdAt": "2026-09-10T00:00:00.000Z", + "updatedAt": "2026-09-10T00:00:00.000Z" + } + }, + { + "entityType": "notebook", + "operation": "upsert", + "entityId": "parent", + "notebook": { + "name": "Parent", + "slug": "parent", + "parentId": null, + "createdAt": "2026-09-10T00:00:00.000Z", + "updatedAt": "2026-09-10T00:00:00.000Z" + } + } + ] }), + ) + .unwrap(); + + assert_eq!( + database + .query_row( + "SELECT slug FROM notebooks WHERE id = 'ws_1_inbox'", + [], + |row| row.get::<_, String>(0), + ) + .unwrap(), + "inbox" + ); + assert_eq!( + database + .query_row( + "SELECT parent_id FROM notebooks WHERE id = 'child'", + [], + |row| row.get::<_, Option>(0), + ) + .unwrap() + .as_deref(), + Some("parent") + ); + assert_eq!( + database + .query_row( + "SELECT notebook_id FROM memos WHERE id = 'page-memo'", + [], + |row| row.get::<_, String>(0), + ) + .unwrap(), + "nb_inbox" + ); + } + + #[test] + fn sync_apply_falls_back_to_renamed_workspace_inbox() { + let database = Connection::open_in_memory().unwrap(); + database + .execute_batch( + "PRAGMA foreign_keys = ON; + CREATE TABLE notebooks ( + id TEXT PRIMARY KEY, + parent_id TEXT REFERENCES notebooks(id), + name TEXT NOT NULL, + slug TEXT, + icon TEXT, + color TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + is_deleted INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT 'now', + updated_at TEXT NOT NULL DEFAULT 'now', + deleted_at TEXT + ); + CREATE TABLE memos ( + id TEXT PRIMARY KEY, + notebook_id TEXT NOT NULL REFERENCES notebooks(id), + title TEXT, + excerpt TEXT NOT NULL, + tags_json TEXT NOT NULL, + is_pinned INTEGER NOT NULL, + is_archived INTEGER NOT NULL, + is_deleted INTEGER NOT NULL, + source_memo_ids TEXT NOT NULL, + merge_source_count INTEGER NOT NULL, + merged_into_memo_id TEXT REFERENCES memos(id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT + ); + CREATE TABLE memo_contents ( + memo_id TEXT PRIMARY KEY REFERENCES memos(id) ON DELETE CASCADE, + content_json TEXT NOT NULL, + content_markdown TEXT NOT NULL, + content_text TEXT NOT NULL, + content_hash TEXT NOT NULL, + revision INTEGER NOT NULL, + updated_at TEXT NOT NULL DEFAULT 'now' + );", + ) + .unwrap(); + + apply_sync_changes( + &database, + &json!({ "changes": [ + { + "entityType": "memo", + "operation": "upsert", + "entityId": "trashed", + "memo": { + "id": "trashed", + "notebookId": "deleted-notebook", + "title": "trashed", + "excerpt": "", + "tags": [], + "isPinned": false, + "isArchived": false, + "isDeleted": true, + "sourceMemoIds": [], + "mergeSourceCount": 0, + "mergedIntoMemoId": null, + "createdAt": "2026-09-10T00:00:00.000Z", + "updatedAt": "2026-09-10T00:00:00.000Z", + "deletedAt": "2026-09-10T00:00:00.000Z", + "contentJson": { "type": "doc", "content": [] }, + "contentMarkdown": "", + "contentText": "", + "contentHash": "trashed", + "revision": 0 + } + }, + { + "entityType": "notebook", + "operation": "upsert", + "entityId": "ws_1_inbox", + "notebook": { + "name": "收集箱", + "slug": "shou-ji-xiang", + "parentId": null, + "createdAt": "2026-09-10T00:00:00.000Z", + "updatedAt": "2026-09-10T00:00:00.000Z" + } + } + ] }), + ) + .unwrap(); + + assert_eq!( + database + .query_row( + "SELECT id, slug FROM notebooks WHERE id = 'ws_1_inbox'", + [], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .unwrap(), + ("ws_1_inbox".to_owned(), "inbox".to_owned()) + ); + assert_eq!( + database + .query_row( + "SELECT notebook_id FROM memos WHERE id = 'trashed'", + [], + |row| row.get::<_, String>(0), + ) + .unwrap(), + "ws_1_inbox" + ); + assert_eq!( + database + .query_row("SELECT COUNT(*) FROM notebooks", [], |row| row + .get::<_, i64>(0)) + .unwrap(), + 1 + ); + } } diff --git a/crates/desktop-sidecar/src/sync.rs b/crates/desktop-sidecar/src/sync.rs index a77e8401..83bdefec 100644 --- a/crates/desktop-sidecar/src/sync.rs +++ b/crates/desktop-sidecar/src/sync.rs @@ -1,6 +1,7 @@ -use rusqlite::Connection; +use rusqlite::{Connection, OptionalExtension, Transaction}; use serde_json::{json, Value}; +use crate::catalog::is_inbox_notebook; use crate::memo::{create_memo, memo_value}; use crate::{bool_param, enqueue_change, memo_remap_base_key, meta_value, set_meta, string_param}; @@ -603,110 +604,263 @@ pub(crate) fn sync_outbox_discard(database: &Connection, params: &Value) -> Resu Ok(json!({ "ok": true })) } +fn order_sync_change_indices(changes: &[Value]) -> Vec { + let mut notebook_upserts = Vec::new(); + let mut notebook_deletes = Vec::new(); + let mut rest = Vec::new(); + for (index, change) in changes.iter().enumerate() { + let entity_type = change + .get("entityType") + .and_then(Value::as_str) + .unwrap_or(""); + let operation = change + .get("operation") + .and_then(Value::as_str) + .unwrap_or(""); + if entity_type == "notebook" { + if operation == "delete" { + notebook_deletes.push(index); + } else { + notebook_upserts.push(index); + } + } else { + rest.push(index); + } + } + + let mut remaining: std::collections::HashSet = + notebook_upserts.iter().copied().collect(); + let mut ordered_upserts = Vec::new(); + while !remaining.is_empty() { + let ready: Vec = notebook_upserts + .iter() + .copied() + .filter(|index| remaining.contains(index)) + .filter(|index| { + let parent_id = changes[*index] + .get("notebook") + .and_then(|notebook| notebook.get("parentId")) + .and_then(Value::as_str); + match parent_id { + Some(parent_id) => !notebook_upserts.iter().copied().any(|candidate| { + remaining.contains(&candidate) + && changes[candidate].get("entityId").and_then(Value::as_str) + == Some(parent_id) + }), + None => true, + } + }) + .collect(); + if ready.is_empty() { + ordered_upserts.extend( + notebook_upserts + .iter() + .copied() + .filter(|index| remaining.contains(index)), + ); + break; + } + for index in ready { + remaining.remove(&index); + ordered_upserts.push(index); + } + } + + ordered_upserts + .into_iter() + .chain(notebook_deletes) + .chain(rest) + .collect() +} + +fn ensure_active_inbox(tx: &Transaction<'_>) -> Result { + if let Some(id) = tx + .query_row( + "SELECT id FROM notebooks + WHERE is_deleted = 0 + AND (slug = 'inbox' OR id = 'nb_inbox' OR id GLOB '*_inbox') + ORDER BY CASE WHEN slug = 'inbox' THEN 0 WHEN id = 'nb_inbox' THEN 1 ELSE 2 END, id + LIMIT 1", + [], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|e| e.to_string())? + { + return Ok(id); + } + + if let Some(id) = tx + .query_row( + "SELECT id FROM notebooks + WHERE slug = 'inbox' OR id = 'nb_inbox' OR id GLOB '*_inbox' + ORDER BY CASE WHEN id = 'nb_inbox' THEN 0 WHEN id GLOB '*_inbox' THEN 1 ELSE 2 END, id + LIMIT 1", + [], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|e| e.to_string())? + { + tx.execute( + "UPDATE notebooks + SET is_deleted = 0, deleted_at = NULL, slug = 'inbox', parent_id = NULL, + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') + WHERE id = ?1", + [&id], + ) + .map_err(|e| e.to_string())?; + return Ok(id); + } + + tx.execute( + "INSERT INTO notebooks (id, parent_id, name, slug, icon, color, sort_order, created_at, updated_at) + VALUES ('nb_inbox', NULL, '等待分类', 'inbox', 'notebook', '#0f766e', 10, + strftime('%Y-%m-%dT%H:%M:%fZ','now'), strftime('%Y-%m-%dT%H:%M:%fZ','now'))", + [], + ) + .map_err(|e| e.to_string())?; + Ok("nb_inbox".to_owned()) +} + +fn resolve_sync_notebook_id( + tx: &Transaction<'_>, + requested_notebook_id: &str, +) -> Result { + let active = tx + .query_row( + "SELECT id FROM notebooks WHERE id = ?1 AND is_deleted = 0", + [requested_notebook_id], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|e| e.to_string())?; + if let Some(id) = active { + return Ok(id); + } + ensure_active_inbox(tx) +} + +fn apply_one_sync_change( + tx: &Transaction<'_>, + change: &Value, + rebuilding: bool, +) -> Result<(), String> { + let entity_type = string_param(change, "entityType")?; + let operation = string_param(change, "operation")?; + let entity_id = string_param(change, "entityId")?; + if rebuilding { + let preserved_table = if entity_type == "notebook" { + "_edgeever_bootstrap_preserved_notebooks" + } else { + "_edgeever_bootstrap_preserved_memos" + }; + let preserved = tx + .query_row( + &format!("SELECT EXISTS(SELECT 1 FROM {preserved_table} WHERE id = ?1)"), + [&entity_id], + |row| row.get::<_, bool>(0), + ) + .unwrap_or(false); + if preserved { + return Ok(()); + } + } + if entity_type == "notebook" { + if operation == "delete" { + tx.execute("UPDATE notebooks SET is_deleted = 1, deleted_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?1 AND slug <> 'inbox' AND id <> 'nb_inbox' AND id NOT GLOB '*_inbox'", [&entity_id]).map_err(|e| e.to_string())?; + return Ok(()); + } + let notebook = change + .get("notebook") + .ok_or_else(|| "Missing notebook change payload".to_owned())?; + let requested_slug = notebook.get("slug").and_then(Value::as_str); + let inbox = is_inbox_notebook(&entity_id, requested_slug); + let requested_parent = notebook + .get("parentId") + .and_then(Value::as_str) + .filter(|id| !id.is_empty() && !inbox); + let parent_id = if let Some(parent_id) = requested_parent { + let exists: bool = tx + .query_row( + "SELECT EXISTS(SELECT 1 FROM notebooks WHERE id = ?1)", + [parent_id], + |row| row.get(0), + ) + .map_err(|e| e.to_string())?; + exists.then_some(parent_id) + } else { + None + }; + let slug = if inbox { Some("inbox") } else { requested_slug }; + tx.execute("INSERT INTO notebooks (id, parent_id, name, slug, icon, color, sort_order, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) ON CONFLICT(id) DO UPDATE SET parent_id=excluded.parent_id, name=excluded.name, slug=excluded.slug, icon=excluded.icon, color=excluded.color, sort_order=excluded.sort_order, updated_at=excluded.updated_at, is_deleted=0", rusqlite::params![entity_id, parent_id, string_param(notebook, "name")?, slug, notebook.get("icon").and_then(Value::as_str), notebook.get("color").and_then(Value::as_str), notebook.get("sortOrder").and_then(Value::as_i64).unwrap_or(0), string_param(notebook, "createdAt")?, string_param(notebook, "updatedAt")?]).map_err(|e| e.to_string())?; + return Ok(()); + } + if operation == "delete" { + tx.execute("UPDATE memos SET is_deleted = 1, deleted_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?1", [&entity_id]).map_err(|e| e.to_string())?; + return Ok(()); + } + let memo = change + .get("memo") + .ok_or_else(|| "Missing memo change payload".to_owned())?; + let tags = memo + .get("tags") + .cloned() + .unwrap_or_else(|| json!([])) + .to_string(); + let source_memo_ids = memo + .get("sourceMemoIds") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let source_ids = Value::Array(source_memo_ids.clone()).to_string(); + let requested_merge_target = memo.get("mergedIntoMemoId").and_then(Value::as_str); + // Bootstrap pages are ordered by memo id, not by merge dependency. A + // deleted source can therefore arrive before the merged memo it + // references. Cache the source without the unresolved foreign key; + // the target's sourceMemoIds backfills it when that page arrives. + let merge_target = if let Some(target_id) = requested_merge_target { + let target_exists = tx + .query_row( + "SELECT EXISTS(SELECT 1 FROM memos WHERE id = ?1)", + [target_id], + |row| row.get::<_, bool>(0), + ) + .map_err(|e| e.to_string())?; + target_exists.then_some(target_id) + } else { + None + }; + let content_json = memo + .get("contentJson") + .cloned() + .unwrap_or_else(|| json!({"type":"doc","content":[]})); + let requested_notebook_id = string_param(memo, "notebookId")?; + let notebook_id = resolve_sync_notebook_id(tx, &requested_notebook_id)?; + tx.execute("INSERT INTO memos (id, notebook_id, title, excerpt, tags_json, is_pinned, is_archived, is_deleted, source_memo_ids, merge_source_count, merged_into_memo_id, created_at, updated_at, deleted_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14) ON CONFLICT(id) DO UPDATE SET notebook_id=excluded.notebook_id, title=excluded.title, excerpt=excluded.excerpt, tags_json=excluded.tags_json, is_pinned=excluded.is_pinned, is_archived=excluded.is_archived, is_deleted=excluded.is_deleted, source_memo_ids=excluded.source_memo_ids, merge_source_count=excluded.merge_source_count, merged_into_memo_id=excluded.merged_into_memo_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at", rusqlite::params![entity_id, notebook_id, memo.get("title").and_then(Value::as_str), string_param(memo, "excerpt")?, tags, memo.get("isPinned").and_then(Value::as_bool).unwrap_or(false) as i64, memo.get("isArchived").and_then(Value::as_bool).unwrap_or(false) as i64, memo.get("isDeleted").and_then(Value::as_bool).unwrap_or(false) as i64, source_ids, memo.get("mergeSourceCount").and_then(Value::as_i64).unwrap_or(0), merge_target, string_param(memo, "createdAt")?, string_param(memo, "updatedAt")?, memo.get("deletedAt").and_then(Value::as_str)]).map_err(|e| e.to_string())?; + tx.execute("INSERT INTO memo_contents (memo_id, content_json, content_markdown, content_text, content_hash, revision) VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT(memo_id) DO UPDATE SET content_json=excluded.content_json, content_markdown=excluded.content_markdown, content_text=excluded.content_text, content_hash=excluded.content_hash, revision=excluded.revision, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')", rusqlite::params![entity_id, content_json.to_string(), string_param(memo, "contentMarkdown")?, string_param(memo, "contentText")?, string_param(memo, "contentHash")?, memo.get("revision").and_then(Value::as_i64).unwrap_or(0)]).map_err(|e| e.to_string())?; + for source_id in source_memo_ids.iter().filter_map(Value::as_str) { + tx.execute( + "UPDATE memos SET merged_into_memo_id = ?1 WHERE id = ?2", + rusqlite::params![entity_id, source_id], + ) + .map_err(|e| e.to_string())?; + } + Ok(()) +} + pub(crate) fn apply_sync_changes(database: &Connection, params: &Value) -> Result { let changes = params .get("changes") .and_then(Value::as_array) .ok_or_else(|| "Missing changes array".to_owned())?; let rebuilding = meta_value(database, SYNC_BOOTSTRAP_RESET_KEY).as_deref() == Some("1"); + let ordered = order_sync_change_indices(changes); let tx = database .unchecked_transaction() .map_err(|e| e.to_string())?; - for change in changes { - let entity_type = string_param(change, "entityType")?; - let operation = string_param(change, "operation")?; - let entity_id = string_param(change, "entityId")?; - if rebuilding { - let preserved_table = if entity_type == "notebook" { - "_edgeever_bootstrap_preserved_notebooks" - } else { - "_edgeever_bootstrap_preserved_memos" - }; - let preserved = tx - .query_row( - &format!("SELECT EXISTS(SELECT 1 FROM {preserved_table} WHERE id = ?1)"), - [&entity_id], - |row| row.get::<_, bool>(0), - ) - .unwrap_or(false); - if preserved { - continue; - } - } - if entity_type == "notebook" { - if operation == "delete" { - tx.execute("UPDATE notebooks SET is_deleted = 1, deleted_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?1", [&entity_id]).map_err(|e| e.to_string())?; - continue; - } - let notebook = change - .get("notebook") - .ok_or_else(|| "Missing notebook change payload".to_owned())?; - tx.execute("INSERT INTO notebooks (id, parent_id, name, slug, icon, color, sort_order, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) ON CONFLICT(id) DO UPDATE SET parent_id=excluded.parent_id, name=excluded.name, slug=excluded.slug, icon=excluded.icon, color=excluded.color, sort_order=excluded.sort_order, updated_at=excluded.updated_at, is_deleted=0", rusqlite::params![entity_id, notebook.get("parentId").and_then(Value::as_str), string_param(notebook, "name")?, notebook.get("slug").and_then(Value::as_str), notebook.get("icon").and_then(Value::as_str), notebook.get("color").and_then(Value::as_str), notebook.get("sortOrder").and_then(Value::as_i64).unwrap_or(0), string_param(notebook, "createdAt")?, string_param(notebook, "updatedAt")?]).map_err(|e| e.to_string())?; - continue; - } - if operation == "delete" { - tx.execute("UPDATE memos SET is_deleted = 1, deleted_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?1", [&entity_id]).map_err(|e| e.to_string())?; - continue; - } - let memo = change - .get("memo") - .ok_or_else(|| "Missing memo change payload".to_owned())?; - let tags = memo - .get("tags") - .cloned() - .unwrap_or_else(|| json!([])) - .to_string(); - let source_memo_ids = memo - .get("sourceMemoIds") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - let source_ids = Value::Array(source_memo_ids.clone()).to_string(); - let requested_merge_target = memo.get("mergedIntoMemoId").and_then(Value::as_str); - // Bootstrap pages are ordered by memo id, not by merge dependency. A - // deleted source can therefore arrive before the merged memo it - // references. Cache the source without the unresolved foreign key; - // the target's sourceMemoIds backfills it when that page arrives. - let merge_target = if let Some(target_id) = requested_merge_target { - let target_exists = tx - .query_row( - "SELECT EXISTS(SELECT 1 FROM memos WHERE id = ?1)", - [target_id], - |row| row.get::<_, bool>(0), - ) - .map_err(|e| e.to_string())?; - target_exists.then_some(target_id) - } else { - None - }; - let content_json = memo - .get("contentJson") - .cloned() - .unwrap_or_else(|| json!({"type":"doc","content":[]})); - let requested_notebook_id = string_param(memo, "notebookId")?; - let notebook_id = tx - .query_row( - "SELECT CASE - WHEN EXISTS(SELECT 1 FROM notebooks WHERE id = ?1 AND is_deleted = 0) THEN ?1 - ELSE (SELECT id FROM notebooks WHERE slug = 'inbox' AND is_deleted = 0 ORDER BY id LIMIT 1) - END", - [&requested_notebook_id], - |row| row.get::<_, Option>(0), - ) - .map_err(|e| e.to_string())? - .ok_or_else(|| { - "Sync memo references an unavailable notebook, and the local inbox is unavailable" - .to_owned() - })?; - tx.execute("INSERT INTO memos (id, notebook_id, title, excerpt, tags_json, is_pinned, is_archived, is_deleted, source_memo_ids, merge_source_count, merged_into_memo_id, created_at, updated_at, deleted_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14) ON CONFLICT(id) DO UPDATE SET notebook_id=excluded.notebook_id, title=excluded.title, excerpt=excluded.excerpt, tags_json=excluded.tags_json, is_pinned=excluded.is_pinned, is_archived=excluded.is_archived, is_deleted=excluded.is_deleted, source_memo_ids=excluded.source_memo_ids, merge_source_count=excluded.merge_source_count, merged_into_memo_id=excluded.merged_into_memo_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at", rusqlite::params![entity_id, notebook_id, memo.get("title").and_then(Value::as_str), string_param(memo, "excerpt")?, tags, memo.get("isPinned").and_then(Value::as_bool).unwrap_or(false) as i64, memo.get("isArchived").and_then(Value::as_bool).unwrap_or(false) as i64, memo.get("isDeleted").and_then(Value::as_bool).unwrap_or(false) as i64, source_ids, memo.get("mergeSourceCount").and_then(Value::as_i64).unwrap_or(0), merge_target, string_param(memo, "createdAt")?, string_param(memo, "updatedAt")?, memo.get("deletedAt").and_then(Value::as_str)]).map_err(|e| e.to_string())?; - tx.execute("INSERT INTO memo_contents (memo_id, content_json, content_markdown, content_text, content_hash, revision) VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT(memo_id) DO UPDATE SET content_json=excluded.content_json, content_markdown=excluded.content_markdown, content_text=excluded.content_text, content_hash=excluded.content_hash, revision=excluded.revision, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')", rusqlite::params![entity_id, content_json.to_string(), string_param(memo, "contentMarkdown")?, string_param(memo, "contentText")?, string_param(memo, "contentHash")?, memo.get("revision").and_then(Value::as_i64).unwrap_or(0)]).map_err(|e| e.to_string())?; - for source_id in source_memo_ids.iter().filter_map(Value::as_str) { - tx.execute( - "UPDATE memos SET merged_into_memo_id = ?1 WHERE id = ?2", - rusqlite::params![entity_id, source_id], - ) - .map_err(|e| e.to_string())?; - } + for index in ordered { + apply_one_sync_change(&tx, &changes[index], rebuilding)?; } tx.commit().map_err(|e| e.to_string())?; Ok(json!({ "applied": changes.len() })) diff --git a/migrations/0046_restore_workspace_inbox_identity.sql b/migrations/0046_restore_workspace_inbox_identity.sql new file mode 100644 index 00000000..50fe76b7 --- /dev/null +++ b/migrations/0046_restore_workspace_inbox_identity.sql @@ -0,0 +1,26 @@ +-- Keep a stable inbox identity per workspace. Renames must not change the +-- inbox slug, and a previously deleted inbox should come back as the fallback +-- destination for notes whose original notebook is gone. +UPDATE notebooks +SET + slug = 'inbox', + is_deleted = 0, + deleted_at = NULL, + parent_id = NULL, + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') +WHERE (id = 'nb_inbox' OR id = workspace_id || '_inbox') + AND ( + is_deleted = 1 + OR IFNULL(slug, '') <> 'inbox' + OR parent_id IS NOT NULL + ); + +INSERT INTO notebooks (id, workspace_id, parent_id, name, slug, icon, color, sort_order) +SELECT w.id || '_inbox', w.id, NULL, '等待分类', 'inbox', 'notebook', '#0f766e', 10 +FROM workspaces w +WHERE NOT EXISTS ( + SELECT 1 + FROM notebooks n + WHERE n.workspace_id = w.id + AND (n.id = 'nb_inbox' OR n.id = w.id || '_inbox') +); diff --git a/scripts/test-desktop-sidecar.mjs b/scripts/test-desktop-sidecar.mjs index 413c169c..43e95ca8 100644 --- a/scripts/test-desktop-sidecar.mjs +++ b/scripts/test-desktop-sidecar.mjs @@ -41,12 +41,12 @@ assert.ok(seedInbox, "seed inbox notebook should exist"); assert.deepEqual(await request("sync.bootstrap.prepare"), { clearedSeedData: true, rebuiltMirror: false }); assert.equal((await request("memo.list", { limit: 20 })).totalCount, 0, "bootstrap preparation should remove only pristine seed data"); assert.equal((await request("notebook.list")).notebooks.length, 0, "bootstrap preparation should remove pristine seed notebooks"); -const inbox = (await request("notebook.create", { name: "Inbox" })).notebook; if (process.platform !== "win32") { assert.equal(statSync(dataDir).mode & 0o777, 0o700, "sidecar data directory should be private"); assert.equal(statSync(join(dataDir, "edgeever.sqlite")).mode & 0o777, 0o600, "sidecar database should be private"); } +let inbox; const remoteMemo = (id, { mergedIntoMemoId = null, sourceMemoIds = [], @@ -76,6 +76,61 @@ const remoteMemo = (id, { const applyRemoteMemo = (memo) => request("sync.apply", { changes: [{ entityType: "memo", operation: "upsert", entityId: memo.id, memo, notebook: null }], }); +const notebookPayload = (id, { name = id, slug = id, parentId = null } = {}) => ({ + id, + parentId, + name, + slug, + icon: "notebook", + color: "#0f766e", + sortOrder: 10, + createdAt: "2026-09-06T00:00:00.000Z", + updatedAt: "2026-09-06T00:00:00.000Z", +}); + +// Regression for #378: first pull after seed clear can receive memos before the +// renamed workspace inbox, and must not fail when slug is no longer `inbox`. +await request("sync.apply", { + changes: [ + { + entityType: "memo", + operation: "upsert", + entityId: "memo_e2e_renamed_inbox", + memo: remoteMemo("memo_e2e_renamed_inbox", { + notebookId: "notebook_deleted_on_server", + isDeleted: true, + }), + notebook: null, + }, + { + entityType: "notebook", + operation: "upsert", + entityId: "ws_1_inbox", + notebook: notebookPayload("ws_1_inbox", { name: "收集箱", slug: "shou-ji-xiang" }), + memo: null, + }, + ], +}); +inbox = (await request("notebook.list")).notebooks.find((notebook) => notebook.id === "ws_1_inbox"); +assert.ok(inbox, "sidecar should keep the workspace inbox even when its remote slug was renamed"); +assert.equal(inbox.slug, "inbox", "synced inbox identity should restore slug=inbox"); +assert.equal( + (await request("memo.get", { memoId: "memo_e2e_renamed_inbox", includeDeleted: true })).memo.notebookId, + inbox.id, + "a missing remote notebook should fall back to the workspace inbox in the same page", +); +assert.equal((await request("memo.emptyTrash")).deleted, 1, "renamed-inbox fixture should not leak into later scenarios"); + +await applyRemoteMemo(remoteMemo("memo_e2e_recreated_inbox", { + notebookId: "notebook_also_missing", + isDeleted: true, +})); +assert.equal( + (await request("memo.get", { memoId: "memo_e2e_recreated_inbox", includeDeleted: true })).memo.notebookId, + inbox.id, + "later pulls should keep using the restored inbox", +); +assert.equal((await request("memo.emptyTrash")).deleted, 1, "recreated-inbox fixture should not leak into later scenarios"); // Regression for #362: bootstrap memo pages are sorted by id, so a deleted // merge source can reach the real sidecar process before its merge target. @@ -412,4 +467,4 @@ assert.equal((await request("sync.status")).conflict, 0); child.stdin.end(); await new Promise((resolve) => child.once("close", resolve)); -console.log(JSON.stringify({ ok: true, checked: ["memo.create", "memo.list.search", "memo.list.tag", "memo.list.subtree", "memo.update", "memo.update.coalesce", "memo.revisions", "memo.restoreRevision", "memo.revision.cache", "tag.rename", "memo.moveBatch", "memo.pinBatch", "memo.deleteBatch", "memo.restore", "memo.emptyTrash", "memo.merge", "template.cache", "template.create.payload", "template.delete", "storage.backup", "storage.backups", "storage.restore", "sync.apply.merge-page-order", "sync.apply.deleted-notebook", "sync.outbox", "sync.outbox.retry", "sync.outbox.recoverMemoUpdate", "sync.outbox.discard"] })); +console.log(JSON.stringify({ ok: true, checked: ["memo.create", "memo.list.search", "memo.list.tag", "memo.list.subtree", "memo.update", "memo.update.coalesce", "memo.revisions", "memo.restoreRevision", "memo.revision.cache", "tag.rename", "memo.moveBatch", "memo.pinBatch", "memo.deleteBatch", "memo.restore", "memo.emptyTrash", "memo.merge", "template.cache", "template.create.payload", "template.delete", "storage.backup", "storage.backups", "storage.restore", "sync.apply.merge-page-order", "sync.apply.deleted-notebook", "sync.apply.renamed-inbox", "sync.outbox", "sync.outbox.retry", "sync.outbox.recoverMemoUpdate", "sync.outbox.discard"] }));