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:
1 parent
d41d068e0c
commit
cc874cd3f9
14 files changed
+919
-111
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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, {});
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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",
|
||||
[¬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<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'", [¬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",
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
);
|
||||
@@ -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"] }));
|
||||
Reference in new issue
Block a user