fix(desktop): preserve notebook dependencies during sync
This commit is contained in:
1 parent
89193d3986
commit
d18ccc2e9b
4 files changed
+233
-18
No files matched your search
@@ -70,6 +70,103 @@ test("desktop notebook create announces its durable id after sidecar acknowledgm
|
||||
}
|
||||
});
|
||||
|
||||
test("desktop creates notebooks before notes and rereads remapped queue versions", async () => {
|
||||
const previousWindow = globalThis.window;
|
||||
const previousNavigator = globalThis.navigator;
|
||||
const originalCreateNotebook = api.createNotebook;
|
||||
const originalCreateMemo = api.createMemo;
|
||||
const calls = [];
|
||||
let queue = [
|
||||
{ id: 1, version: 1, kind: "notebook.create", entityId: "nb_local_parent", payload: { name: "对对对" } },
|
||||
...[2, 3].map((id) => ({ id, version: 1, kind: "memo.create", entityId: `memo_local_${id}`, payload: { notebookId: "nb_local_parent", contentMarkdown: `body ${id}` } })),
|
||||
];
|
||||
globalThis.window = {
|
||||
dispatchEvent: () => true,
|
||||
edgeeverDesktop: {
|
||||
isAvailable: true,
|
||||
listStagedResources: async () => [],
|
||||
listStagedResourceAliases: async () => [],
|
||||
sidecarRequest: async (method, params) => {
|
||||
if (method === "sync.outbox.list") return { items: queue.map((item) => ({ ...item, payload: { ...item.payload } })) };
|
||||
if (method === "sync.status") return { pending: 1, syncing: 0 };
|
||||
if (method === "sync.outbox.ack") {
|
||||
const current = queue.find((item) => item.id === params.id);
|
||||
expect(params.version).toBe(current.version);
|
||||
queue = queue.filter((item) => item.id !== params.id);
|
||||
if (params.remoteNotebook) {
|
||||
queue = queue.map((item) => ({ ...item, version: item.version + 1, payload: { ...item.payload, notebookId: "nb_cloud" } }));
|
||||
}
|
||||
}
|
||||
if (method === "sync.outbox.fail") throw new Error("No dependency should fail");
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
};
|
||||
globalThis.navigator = { onLine: true };
|
||||
api.createNotebook = async () => {
|
||||
calls.push("notebook");
|
||||
return { notebook: { id: "nb_cloud", name: "对对对", parentId: null } };
|
||||
};
|
||||
api.createMemo = async (payload) => {
|
||||
expect(payload.notebookId).toBe("nb_cloud");
|
||||
calls.push(payload.contentMarkdown);
|
||||
return { memo: { id: `memo_cloud_${calls.length}`, contentMarkdown: payload.contentMarkdown, contentJson: { type: "doc", content: [] } } };
|
||||
};
|
||||
try {
|
||||
const result = await syncDesktopData();
|
||||
expect(result.failed).toBe(0);
|
||||
expect(result.synced).toBe(3);
|
||||
expect(calls).toEqual(["notebook", "body 2", "body 3"]);
|
||||
expect(queue).toEqual([]);
|
||||
} finally {
|
||||
api.createNotebook = originalCreateNotebook;
|
||||
api.createMemo = originalCreateMemo;
|
||||
globalThis.navigator = previousNavigator;
|
||||
if (previousWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = previousWindow;
|
||||
}
|
||||
});
|
||||
|
||||
test("desktop leaves dependent notes pending when their notebook upload fails", async () => {
|
||||
const previousWindow = globalThis.window;
|
||||
const previousNavigator = globalThis.navigator;
|
||||
const originalCreateNotebook = api.createNotebook;
|
||||
const originalCreateMemo = api.createMemo;
|
||||
const failures = [];
|
||||
let notebookEligible = true;
|
||||
const notebook = { id: 1, version: 1, attemptCount: 0, kind: "notebook.create", entityId: "nb_local_failed", payload: { name: "Pending" } };
|
||||
const note = { id: 2, version: 1, kind: "memo.create", entityId: "memo_local_waiting", payload: { notebookId: notebook.entityId } };
|
||||
globalThis.window = {
|
||||
dispatchEvent: () => true,
|
||||
edgeeverDesktop: {
|
||||
isAvailable: true,
|
||||
listStagedResources: async () => [],
|
||||
listStagedResourceAliases: async () => [],
|
||||
sidecarRequest: async (method, params) => {
|
||||
if (method === "sync.outbox.list") return { items: notebookEligible ? [notebook, note] : [note] };
|
||||
if (method === "sync.status") return { pending: 1, syncing: 0, error: 1 };
|
||||
if (method === "sync.outbox.fail") { failures.push(params.id); notebookEligible = false; }
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
};
|
||||
globalThis.navigator = { onLine: true };
|
||||
api.createNotebook = async () => { throw new TypeError("Network unavailable"); };
|
||||
api.createMemo = async () => { throw new Error("Dependent notes must wait"); };
|
||||
try {
|
||||
const result = await syncDesktopData();
|
||||
expect(result.failed).toBe(1);
|
||||
expect(result.attempted).toBe(1);
|
||||
expect(failures).toEqual([notebook.id]);
|
||||
} finally {
|
||||
api.createNotebook = originalCreateNotebook;
|
||||
api.createMemo = originalCreateMemo;
|
||||
globalThis.navigator = previousNavigator;
|
||||
if (previousWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = previousWindow;
|
||||
}
|
||||
});
|
||||
|
||||
test("desktop explicit discard clears a conflict whose cloud note is gone", async () => {
|
||||
const previousWindow = globalThis.window;
|
||||
const originalGetMemo = api.getMemo;
|
||||
|
||||
@@ -570,8 +570,16 @@ const syncOutbox = async (stagedRewrites: StagedResourceRewrite[], onlyKinds?: S
|
||||
const response = await request("sync.outbox.list", { limit: 100 });
|
||||
const memoIdMappings = new Map<string, string>();
|
||||
const syncedMemos = new Map<string, DesktopRpcResponses["memo.get"]["memo"]>();
|
||||
for (const item of response.items) {
|
||||
let currentItems = response.items;
|
||||
for (const candidate of response.items) {
|
||||
// Earlier creates may have remapped this item and advanced its version.
|
||||
const item = currentItems.find((current) => current.id === candidate.id);
|
||||
if (!item) continue;
|
||||
if (onlyKinds && !onlyKinds.has(item.kind)) continue;
|
||||
// A local dependency has no cloud id yet. Leave the action pending until
|
||||
// its create succeeds rather than making a permanent not-found error.
|
||||
if ([item.payload.notebookId, item.payload.parentId].some((id) => typeof id === "string" && id.startsWith("nb_local_"))) continue;
|
||||
if (item.kind !== "memo.create" && [item.entityId, item.payload.memoId].some((id) => typeof id === "string" && id.startsWith("memo_local_"))) continue;
|
||||
try {
|
||||
const result = await syncOutboxItem(item, stagedRewrites);
|
||||
if (item.kind === "memo.create" && result && typeof result === "object" && "id" in result && typeof result.id === "string") {
|
||||
@@ -590,6 +598,9 @@ const syncOutbox = async (stagedRewrites: StagedResourceRewrite[], onlyKinds?: S
|
||||
notifyMemoSyncAcknowledged(syncedMemo);
|
||||
}
|
||||
synced += 1;
|
||||
if (["notebook.create", "memo.create", "memo.merge"].includes(item.kind)) {
|
||||
currentItems = (await request("sync.outbox.list", { limit: 100 })).items;
|
||||
}
|
||||
} catch (error) {
|
||||
const disposition = classifyDesktopSyncFailure(item, error);
|
||||
await request("sync.outbox.fail", {
|
||||
@@ -605,7 +616,7 @@ const syncOutbox = async (stagedRewrites: StagedResourceRewrite[], onlyKinds?: S
|
||||
else failed += 1;
|
||||
}
|
||||
}
|
||||
return { attempted: onlyKinds ? synced + failed + conflicted : response.items.length, synced, failed, conflicted, memoIdMappings, syncedMemos };
|
||||
return { attempted: synced + failed + conflicted, synced, failed, conflicted, memoIdMappings, syncedMemos };
|
||||
};
|
||||
|
||||
const applyBootstrap = async (page: SyncBootstrapResponse) => {
|
||||
@@ -805,7 +816,7 @@ export const syncDesktopData = () => {
|
||||
}
|
||||
let phase: DesktopSyncPhase = "sync_creates";
|
||||
try {
|
||||
const creates = await syncOutbox([], new Set(["memo.create"]));
|
||||
const creates = await syncOutbox([], new Set(["notebook.create", "memo.create"]));
|
||||
mergeMemoIdMappings(memoIdMappings, creates.memoIdMappings);
|
||||
mergeSyncedMemos(syncedMemos, creates.syncedMemos);
|
||||
phase = "remap_created_resources";
|
||||
|
||||
@@ -328,7 +328,11 @@ pub(crate) fn sync_outbox_ack(database: &Connection, params: &Value) -> Result<V
|
||||
} else {
|
||||
outbox_entity
|
||||
};
|
||||
database.execute("UPDATE _edgeever_sidecar_outbox SET entity_id = ?1, payload_json = ?2, version = version + 1 WHERE id = ?3", rusqlite::params![next_entity, payload.to_string(), outbox_id]).map_err(|e| e.to_string())?;
|
||||
database.execute("UPDATE _edgeever_sidecar_outbox SET entity_id = ?1, payload_json = ?2, version = version + 1,
|
||||
status = CASE WHEN status = 'error' AND last_error_code = 'memo_not_found' THEN 'pending' ELSE status END,
|
||||
retryable = CASE WHEN status = 'error' AND last_error_code = 'memo_not_found' THEN 1 ELSE retryable END,
|
||||
next_attempt_at = CASE WHEN status = 'error' AND last_error_code = 'memo_not_found' THEN NULL ELSE next_attempt_at END
|
||||
WHERE id = ?3", rusqlite::params![next_entity, payload.to_string(), outbox_id]).map_err(|e| e.to_string())?;
|
||||
}
|
||||
if kind == "memo.create" {
|
||||
if let Some((temporary_revision, temporary_content_hash)) =
|
||||
@@ -352,26 +356,39 @@ pub(crate) fn sync_outbox_ack(database: &Connection, params: &Value) -> Result<V
|
||||
if kind == "notebook.create" {
|
||||
if let Some(remote) = remote_notebook.as_ref() {
|
||||
let remote_id = string_param(remote, "id")?;
|
||||
let remote_exists: bool = database
|
||||
let tx = database
|
||||
.unchecked_transaction()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let remote_exists: bool = tx
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM notebooks WHERE id = ?1)",
|
||||
[&remote_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if remote_exists {
|
||||
database
|
||||
.execute("DELETE FROM notebooks WHERE id = ?1", [&entity_id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
database
|
||||
.execute(
|
||||
"UPDATE notebooks SET id = ?1 WHERE id = ?2",
|
||||
rusqlite::params![remote_id, entity_id],
|
||||
)
|
||||
if remote_exists && remote_id != entity_id {
|
||||
// The renderer caches the cloud notebook before acknowledging it.
|
||||
// Move notes and children before deleting the local placeholder.
|
||||
tx.execute(
|
||||
"UPDATE memos SET notebook_id = ?1 WHERE notebook_id = ?2",
|
||||
rusqlite::params![remote_id, entity_id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
tx.execute(
|
||||
"UPDATE notebooks SET parent_id = ?1 WHERE parent_id = ?2",
|
||||
rusqlite::params![remote_id, entity_id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
tx.execute("DELETE FROM notebooks WHERE id = ?1", [&entity_id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
} else if !remote_exists {
|
||||
tx.execute(
|
||||
"UPDATE notebooks SET id = ?1 WHERE id = ?2",
|
||||
rusqlite::params![remote_id, entity_id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
let mut pending = database.prepare("SELECT id, entity_id, payload_json FROM _edgeever_sidecar_outbox WHERE id <> ?1").map_err(|e| e.to_string())?;
|
||||
let mut pending = tx.prepare("SELECT id, entity_id, payload_json FROM _edgeever_sidecar_outbox WHERE id <> ?1").map_err(|e| e.to_string())?;
|
||||
let rows: Vec<(i64, String, String)> = pending
|
||||
.query_map([id], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
|
||||
.map_err(|e| e.to_string())?
|
||||
@@ -386,6 +403,9 @@ pub(crate) fn sync_outbox_ack(database: &Connection, params: &Value) -> Result<V
|
||||
if payload.get("notebookId").and_then(Value::as_str) == Some(entity_id.as_str()) {
|
||||
payload["notebookId"] = json!(remote_id);
|
||||
}
|
||||
if payload.get("parentId").and_then(Value::as_str) == Some(entity_id.as_str()) {
|
||||
payload["parentId"] = json!(remote_id);
|
||||
}
|
||||
if payload.get("temporaryId").and_then(Value::as_str) == Some(entity_id.as_str()) {
|
||||
payload["temporaryId"] = json!(remote_id);
|
||||
}
|
||||
@@ -394,8 +414,27 @@ pub(crate) fn sync_outbox_ack(database: &Connection, params: &Value) -> Result<V
|
||||
} else {
|
||||
outbox_entity
|
||||
};
|
||||
database.execute("UPDATE _edgeever_sidecar_outbox SET entity_id = ?1, payload_json = ?2, version = version + 1 WHERE id = ?3", rusqlite::params![next_entity, payload.to_string(), outbox_id]).map_err(|e| e.to_string())?;
|
||||
tx.execute("UPDATE _edgeever_sidecar_outbox SET entity_id = ?1, payload_json = ?2, version = version + 1,
|
||||
status = CASE WHEN status = 'error' AND last_error_code = 'not_found' THEN 'pending' ELSE status END,
|
||||
retryable = CASE WHEN status = 'error' AND last_error_code = 'not_found' THEN 1 ELSE retryable END,
|
||||
next_attempt_at = CASE WHEN status = 'error' AND last_error_code = 'not_found' THEN NULL ELSE next_attempt_at END
|
||||
WHERE id = ?3", rusqlite::params![next_entity, payload.to_string(), outbox_id]).map_err(|e| e.to_string())?;
|
||||
}
|
||||
drop(pending);
|
||||
// Commit the remap and acknowledgement together. Otherwise a crash
|
||||
// can leave an already-created notebook queued for another create.
|
||||
let deleted = tx.execute(
|
||||
"DELETE FROM _edgeever_sidecar_outbox WHERE id = ?1 AND (?2 IS NULL OR version = ?2)",
|
||||
rusqlite::params![id, requested_version],
|
||||
).map_err(|e| e.to_string())?;
|
||||
tx.commit().map_err(|e| e.to_string())?;
|
||||
return Ok(json!({
|
||||
"ok": true,
|
||||
"superseded": deleted == 0,
|
||||
"memo": remote_memo,
|
||||
"notebook": remote_notebook,
|
||||
"template": remote_template
|
||||
}));
|
||||
}
|
||||
}
|
||||
if kind == "template.create" {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { Database } from "bun:sqlite";
|
||||
import { spawn } from "node:child_process";
|
||||
import { createInterface } from "node:readline";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
@@ -521,6 +522,73 @@ const blockedNotebookIds = new Set((await request("notebook.list")).notebooks.ma
|
||||
assert.equal(blockedNotebookIds.has(blockedNotebook.id), true, "a notebook with notes in a child should stay");
|
||||
assert.equal(blockedNotebookIds.has(blockedChild.id), true, "a child notebook that still has notes should stay");
|
||||
|
||||
// Reproduce the installed client's failed notebook acknowledgement with notes
|
||||
// and a child already referring to the placeholder.
|
||||
const pendingNotebook = (await request("notebook.create", { name: "Notebook remap regression" })).notebook;
|
||||
const pendingChild = (await request("notebook.create", { name: "Pending child", parentId: pendingNotebook.id })).notebook;
|
||||
const pendingNotes = [];
|
||||
for (const contentMarkdown of ["first preserved body", "second preserved body"]) {
|
||||
pendingNotes.push((await request("memo.create", { notebookId: pendingNotebook.id, contentMarkdown, tags: [] })).memo);
|
||||
}
|
||||
pendingNotes[0] = (await request("memo.update", {
|
||||
memoId: pendingNotes[0].id,
|
||||
contentMarkdown: "edited body survives recovery",
|
||||
contentJson: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "edited body survives recovery" }] }] },
|
||||
tags: [],
|
||||
})).memo;
|
||||
const notebookQueue = (await request("sync.outbox.list", { limit: 200 })).items;
|
||||
const parentCreate = notebookQueue.find((item) => item.kind === "notebook.create" && item.entityId === pendingNotebook.id);
|
||||
for (const item of notebookQueue.filter((item) => item.kind === "memo.create" && pendingNotes.some((memo) => memo.id === item.entityId))) {
|
||||
await request("sync.outbox.fail", { id: item.id, version: item.version, error: "Notebook not found", errorCode: "not_found", retryable: false });
|
||||
}
|
||||
const dependentUpdate = notebookQueue.find((item) => item.kind === "memo.update" && item.entityId === pendingNotes[0].id);
|
||||
await request("sync.outbox.fail", { id: dependentUpdate.id, version: dependentUpdate.version, error: "Memo not found", errorCode: "memo_not_found", retryable: false });
|
||||
const durableNotebook = { ...pendingNotebook, id: "nb_regression_durable" };
|
||||
await request("sync.apply", { changes: [{ entityType: "notebook", operation: "upsert", entityId: durableNotebook.id, notebook: durableNotebook }] });
|
||||
// Force the final acknowledgement to fail after all remapping statements.
|
||||
// Every relationship and queue payload must roll back together.
|
||||
const inspectionDb = new Database(join(dataDir, "edgeever.sqlite"));
|
||||
inspectionDb.exec(`CREATE TRIGGER fail_notebook_ack BEFORE DELETE ON _edgeever_sidecar_outbox
|
||||
WHEN OLD.id = ${parentCreate.id}
|
||||
BEGIN SELECT RAISE(ABORT, 'injected notebook ack failure'); END`);
|
||||
await assert.rejects(request("sync.outbox.ack", { id: parentCreate.id, version: parentCreate.version, remoteNotebook: durableNotebook }), /injected notebook ack failure/);
|
||||
assert.equal(inspectionDb.query("SELECT notebook_id FROM memos WHERE id = ?").get(pendingNotes[0].id).notebook_id, pendingNotebook.id);
|
||||
assert.equal(inspectionDb.query("SELECT parent_id FROM notebooks WHERE id = ?").get(pendingChild.id).parent_id, pendingNotebook.id);
|
||||
assert.equal(inspectionDb.query("SELECT COUNT(*) AS count FROM notebooks WHERE id = ?").get(pendingNotebook.id).count, 1);
|
||||
const failedRemapQueue = (await request("sync.outbox.list", { limit: 200, includeConflicts: true })).items;
|
||||
for (const original of notebookQueue.filter((item) => item.payload.notebookId === pendingNotebook.id || item.payload.parentId === pendingNotebook.id)) {
|
||||
const current = failedRemapQueue.find((item) => item.id === original.id);
|
||||
assert.equal(current.version, original.version);
|
||||
assert.deepEqual(current.payload, original.payload);
|
||||
}
|
||||
assert.ok(failedRemapQueue.some((item) => item.id === parentCreate.id));
|
||||
inspectionDb.exec("DROP TRIGGER fail_notebook_ack");
|
||||
inspectionDb.close();
|
||||
await request("sync.outbox.ack", { id: parentCreate.id, version: parentCreate.version, remoteNotebook: durableNotebook });
|
||||
const afterNotebooks = (await request("notebook.list")).notebooks;
|
||||
assert.ok(!afterNotebooks.some((notebook) => notebook.id === pendingNotebook.id));
|
||||
assert.equal(afterNotebooks.find((notebook) => notebook.id === pendingChild.id).parentId, durableNotebook.id);
|
||||
const remappedQueue = (await request("sync.outbox.list", { limit: 200 })).items;
|
||||
assert.equal(remappedQueue.find((item) => item.entityId === pendingChild.id).payload.parentId, durableNotebook.id);
|
||||
for (const memo of pendingNotes) {
|
||||
assert.equal((await request("memo.get", { memoId: memo.id })).memo.notebookId, durableNotebook.id);
|
||||
assert.equal((await request("memo.get", { memoId: memo.id })).memo.contentMarkdown, memo.contentMarkdown);
|
||||
const item = remappedQueue.find((item) => item.kind === "memo.create" && item.entityId === memo.id);
|
||||
assert.equal(item.payload.notebookId, durableNotebook.id);
|
||||
assert.equal(item.status, "pending", "dependency errors must become eligible after remapping");
|
||||
assert.equal(item.version, notebookQueue.find((previous) => previous.id === item.id).version + 1);
|
||||
}
|
||||
|
||||
const recoveredCreate = remappedQueue.find((item) => item.kind === "memo.create" && item.entityId === pendingNotes[0].id);
|
||||
const recoveredRemoteMemo = { ...pendingNotes[0], id: "memo_recovered_notebook", notebookId: durableNotebook.id, revision: 1 };
|
||||
await request("sync.outbox.ack", { id: recoveredCreate.id, version: recoveredCreate.version, remoteMemo: recoveredRemoteMemo });
|
||||
const recoveredUpdate = (await request("sync.outbox.list", { limit: 200 })).items.find((item) => item.id === dependentUpdate.id);
|
||||
assert.equal(recoveredUpdate.entityId, recoveredRemoteMemo.id);
|
||||
assert.equal(recoveredUpdate.payload.memoId, recoveredRemoteMemo.id);
|
||||
assert.equal(recoveredUpdate.payload.contentMarkdown, "edited body survives recovery");
|
||||
assert.equal(recoveredUpdate.payload.expectedRevision, 1);
|
||||
assert.equal(recoveredUpdate.status, "pending", "missing-memo edits should recover after memo create acknowledgement");
|
||||
|
||||
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.noteKind", "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", "notebook.delete.empty-tree", "notebook.delete.not-empty"] }));
|
||||
console.log(JSON.stringify({ ok: true, checked: ["memo.create", "memo.list.search", "memo.list.noteKind", "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.notebook-remap", "sync.outbox.notebook-remap-rollback", "sync.outbox.retry", "sync.outbox.recoverMemoUpdate", "sync.outbox.discard", "notebook.delete.empty-tree", "notebook.delete.not-empty"] }));
|
||||
Reference in new issue
Block a user