fix(sync): keep desktop pull working without a slug inbox

Identify the workspace inbox by stable id, restore it when missing, and apply notebooks before memos so a renamed or deleted inbox cannot abort the whole pull.

Fixes #378
This commit is contained in:
tianma-if committed 2026-09-10 11:52:01 +08:00
1 parent d41d068e0c
commit cc874cd3f9
14 files changed
+919 -111

No files matched your search

+8 -1
View File
@@ -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);
+106
View File
@@ -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();
}
});
});
+13 -4
View File
@@ -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, {});
};
+61
View File
@@ -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 {
+7 -2
View File
@@ -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`;
@@ -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;
+12 -1
View File
@@ -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 },
+25
View File
@@ -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", () => {
+29 -3
View File
@@ -495,9 +495,9 @@ export const shouldAttemptDesktopRecoveryPull = (
&& phase !== "finalize_resource_remap"
&& shouldPullDesktopChanges(status, online);
export const orderBootstrapNotebooks = (notebooks: SyncBootstrapResponse["notebooks"]) => {
export const orderBootstrapNotebooks = <T extends { id: string; parentId?: string | null }>(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 = <T extends {
entityType: string;
entityId: string;
operation: string;
notebook?: { id: string; parentId?: string | null } | null;
}>(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 });
+18 -2
View File
@@ -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<Value, String> {
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<V
pub(crate) fn update_notebook(database: &Connection, params: &Value) -> Result<Value, String> {
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<String> = database
.query_row(
"SELECT slug FROM notebooks WHERE id = ?1",
[&notebook_id],
|row| row.get(0),
)
.map_err(|e| e.to_string())?;
let slug = if is_inbox_notebook(&notebook_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<V
if child_count > 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'", [&notebook_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'", [&notebook_id]).map_err(|e| e.to_string())?;
enqueue_change(
database,
"notebook.delete",
+295
View File
@@ -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<String>>(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
);
}
}
+250 -96
View File
@@ -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<usize> {
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<usize> =
notebook_upserts.iter().copied().collect();
let mut ordered_upserts = Vec::new();
while !remaining.is_empty() {
let ready: Vec<usize> = 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<String, String> {
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<String, String> {
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<Value, String> {
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<String>>(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() }))
@@ -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')
);
+57 -2
View File
@@ -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"] }));