fix(sync): clear conflicts after confirmed memo deletion
This commit is contained in:
1 parent
27f3ce7749
commit
a7f55da099
9 files changed
+260
-19
No files matched your search
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
|
||||
|
||||
const {
|
||||
classifyDesktopSyncFailure,
|
||||
discardDesktopConflicts,
|
||||
createDesktopSyncIssueDetails,
|
||||
createDesktopSyncDiagnosticText,
|
||||
createDesktopSyncSummary,
|
||||
@@ -22,7 +23,33 @@ const {
|
||||
shouldAttemptDesktopRecoveryPull,
|
||||
shouldPullDesktopChanges,
|
||||
} = await import("./desktop-sync.ts");
|
||||
const { ApiRequestError } = await import("./api.ts");
|
||||
const { api, ApiRequestError } = await import("./api.ts");
|
||||
|
||||
test("desktop explicit discard clears a conflict whose cloud note is gone", async () => {
|
||||
const previousWindow = globalThis.window;
|
||||
const originalGetMemo = api.getMemo;
|
||||
const calls = [];
|
||||
globalThis.window = {
|
||||
edgeeverDesktop: {
|
||||
isAvailable: true,
|
||||
sidecarRequest: async (method, params) => {
|
||||
calls.push({ method, params });
|
||||
if (method === "sync.outbox.list") return { items: [{ id: 7, version: 2, kind: "memo.update", entityId: "memo-gone", status: "conflict" }] };
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
};
|
||||
api.getMemo = async () => { throw new ApiRequestError("Not found", 404, "not_found"); };
|
||||
try {
|
||||
expect(await discardDesktopConflicts()).toBe(1);
|
||||
expect(calls.map((call) => call.method)).toEqual(["sync.outbox.list", "sync.outbox.discard"]);
|
||||
expect(calls[1].params).toEqual({ id: 7, version: 2 });
|
||||
} finally {
|
||||
api.getMemo = originalGetMemo;
|
||||
if (previousWindow === undefined) delete globalThis.window;
|
||||
else globalThis.window = previousWindow;
|
||||
}
|
||||
});
|
||||
|
||||
describe("desktop staged resource sync", () => {
|
||||
test("holds cloud sync until an imported screenshot is saved", async () => {
|
||||
|
||||
@@ -959,9 +959,19 @@ export const discardDesktopConflicts = async () => {
|
||||
for (const item of conflicts) {
|
||||
if (item.kind === "memo.update") {
|
||||
// Replace the local conflicted draft with the authoritative remote memo
|
||||
// before removing the queue item. If the remote read fails, keep the
|
||||
// conflict so the user's local changes remain recoverable.
|
||||
const remote = await api.getMemo(item.entityId, { includeDeleted: true });
|
||||
// before removing the queue item. Keep the conflict on read failures
|
||||
// unless the server confirms that the note no longer exists.
|
||||
let remote: Awaited<ReturnType<typeof api.getMemo>>;
|
||||
try {
|
||||
remote = await api.getMemo(item.entityId, { includeDeleted: true });
|
||||
} catch (error) {
|
||||
if (!(error instanceof ApiRequestError) || error.status !== 404) throw error;
|
||||
// Explicit discard can resolve a conflict left behind after the note
|
||||
// was permanently deleted and no cloud version exists to adopt.
|
||||
await request("sync.outbox.discard", { id: item.id, version: item.version });
|
||||
discarded += 1;
|
||||
continue;
|
||||
}
|
||||
await request("sync.apply", {
|
||||
changes: [{ entityType: "memo", operation: "upsert", entityId: remote.memo.id, memo: remote.memo, notebook: null }],
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ globalThis.IDBKeyRange = IDBKeyRange;
|
||||
const { localDb } = await import("./local-db.ts");
|
||||
const { api } = await import("./api.ts");
|
||||
const { createWebRepository } = await import("./repository.ts");
|
||||
const { createLocalMemo } = await import("./local-mirror.ts");
|
||||
const { createLocalMemo, putLocalMemo } = await import("./local-mirror.ts");
|
||||
|
||||
const installTestWindow = ({ hostname = "localhost" } = {}) => {
|
||||
const previousWindow = globalThis.window;
|
||||
@@ -36,6 +36,24 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("web repository offline boundaries", () => {
|
||||
test("empty trash records the removed memo ids for conflict cleanup after sync", async () => {
|
||||
const restoreWindow = installTestWindow();
|
||||
const scope = "https://demo.edgeever.org|user-1";
|
||||
const trashed = await createLocalMemo(scope, { notebookId: "nb-1" });
|
||||
const active = await createLocalMemo(scope, { notebookId: "nb-1" });
|
||||
await putLocalMemo(scope, { ...trashed, isDeleted: true });
|
||||
try {
|
||||
expect(await createWebRepository(scope).emptyTrash()).toEqual({ ok: true, deleted: 1 });
|
||||
await putLocalMemo(scope, { ...active, isDeleted: true });
|
||||
expect(await createWebRepository(scope).emptyTrash()).toEqual({ ok: true, deleted: 1 });
|
||||
const action = await localDb.syncQueue.get(`action:${scope}:memo.emptyTrash:trash`);
|
||||
expect(action?.payload).toEqual({ memoIds: [trashed.id, active.id] });
|
||||
expect(await localDb.memos.get([scope, active.id])).toBeUndefined();
|
||||
} finally {
|
||||
restoreWindow();
|
||||
}
|
||||
});
|
||||
|
||||
test("reconciles stale template cache entries while preserving queued local edits", async () => {
|
||||
const previousOnline = globalThis.navigator?.onLine;
|
||||
if (globalThis.navigator) Object.defineProperty(globalThis.navigator, "onLine", { configurable: true, value: true });
|
||||
|
||||
@@ -413,8 +413,11 @@ export const createWebRepository = (scope: string): EdgeEverRepository => {
|
||||
return { ok: true as const, deleted };
|
||||
},
|
||||
emptyTrash: async () => {
|
||||
const memoIds = (await localDb.memos.where("scope").equals(scope).toArray())
|
||||
.filter((memo) => memo.isDeleted)
|
||||
.map((memo) => memo.id);
|
||||
const deleted = await applyLocalEmptyTrash(scope);
|
||||
await queueLocalAction(scope, "memo.emptyTrash", "trash", {});
|
||||
await queueLocalAction(scope, "memo.emptyTrash", "trash", { memoIds });
|
||||
window.dispatchEvent(new CustomEvent("edgeever:sync-queue-changed"));
|
||||
return { ok: true as const, deleted };
|
||||
},
|
||||
|
||||
@@ -5,9 +5,9 @@ globalThis.indexedDB = indexedDB;
|
||||
globalThis.IDBKeyRange = IDBKeyRange;
|
||||
|
||||
const { localDb } = await import("./local-db.ts");
|
||||
const { api } = await import("./api.ts");
|
||||
const { api, ApiRequestError } = await import("./api.ts");
|
||||
const { createLocalDataScope, createLocalMemo, getLocalMemo, putLocalMemo, replaceLocalMemoId, createLocalResource, listLocalResources } = await import("./local-mirror.ts");
|
||||
const { discardWebConflicts, getMemoUpdateQueueId, queueLocalAction, queueMemoCreate, queueMemoUpdate, syncQueuedChanges } = await import("./sync-queue.ts");
|
||||
const { discardWebConflicts, getMemoUpdateQueueId, queueLocalAction, queueMemoCreate, queueMemoDelete, queueMemoUpdate, syncQueuedChanges } = await import("./sync-queue.ts");
|
||||
|
||||
afterEach(async () => {
|
||||
await localDb.transaction(
|
||||
@@ -30,6 +30,60 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("web sync conflict recovery", () => {
|
||||
test("confirmed note deletions clear only their older local conflicts", async () => {
|
||||
const previousOnline = globalThis.navigator?.onLine;
|
||||
if (globalThis.navigator) Object.defineProperty(globalThis.navigator, "onLine", { configurable: true, value: true });
|
||||
const scope = createLocalDataScope("https://demo.edgeever.org", "user-1");
|
||||
const old = "2026-01-01T00:00:00.000Z";
|
||||
for (const memoId of ["memo-single", "memo-batch", "memo-trash", "memo-unrelated"]) {
|
||||
await localDb.syncQueue.put({
|
||||
id: getMemoUpdateQueueId(memoId), kind: "memo.update", scope, memoId,
|
||||
status: "conflict", payload: { memoId }, attemptCount: 1,
|
||||
lastError: "conflict", nextAttemptAt: null, claimId: null,
|
||||
createdAt: old, updatedAt: old,
|
||||
});
|
||||
}
|
||||
await queueMemoDelete(scope, { memoId: "memo-single", permanent: false });
|
||||
await queueLocalAction(scope, "memo.deleteBatch", "batch", { memoIds: ["memo-batch"], permanent: true });
|
||||
await queueLocalAction(scope, "memo.emptyTrash", "trash", { memoIds: ["memo-trash"] });
|
||||
const original = { deleteMemo: api.deleteMemo, deleteMemos: api.deleteMemos, emptyTrash: api.emptyTrash };
|
||||
api.deleteMemo = async () => ({ ok: true });
|
||||
api.deleteMemos = async () => ({ ok: true, deleted: 1 });
|
||||
api.emptyTrash = async () => ({ ok: true, deleted: 1 });
|
||||
try {
|
||||
expect(await syncQueuedChanges({ scope })).toMatchObject({ synced: 3, conflicted: 0 });
|
||||
for (const memoId of ["memo-single", "memo-batch", "memo-trash"]) {
|
||||
expect(await localDb.syncQueue.get(getMemoUpdateQueueId(memoId))).toBeUndefined();
|
||||
}
|
||||
expect(await localDb.syncQueue.get(getMemoUpdateQueueId("memo-unrelated"))).toBeDefined();
|
||||
} finally {
|
||||
Object.assign(api, original);
|
||||
if (globalThis.navigator) Object.defineProperty(globalThis.navigator, "onLine", { configurable: true, value: previousOnline });
|
||||
}
|
||||
});
|
||||
|
||||
test("failed note deletion preserves the local conflict", async () => {
|
||||
const previousOnline = globalThis.navigator?.onLine;
|
||||
if (globalThis.navigator) Object.defineProperty(globalThis.navigator, "onLine", { configurable: true, value: true });
|
||||
const scope = createLocalDataScope("https://demo.edgeever.org", "user-1");
|
||||
await localDb.syncQueue.put({
|
||||
id: getMemoUpdateQueueId("memo-failed"), kind: "memo.update", scope, memoId: "memo-failed",
|
||||
status: "conflict", payload: { memoId: "memo-failed" }, attemptCount: 1,
|
||||
lastError: "conflict", nextAttemptAt: null, claimId: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
await queueMemoDelete(scope, { memoId: "memo-failed", permanent: false });
|
||||
const original = api.deleteMemo;
|
||||
api.deleteMemo = async () => { throw new Error("offline"); };
|
||||
try {
|
||||
expect(await syncQueuedChanges({ scope })).toMatchObject({ failed: 1 });
|
||||
expect(await localDb.syncQueue.get(getMemoUpdateQueueId("memo-failed"))).toMatchObject({ status: "conflict" });
|
||||
} finally {
|
||||
api.deleteMemo = original;
|
||||
if (globalThis.navigator) Object.defineProperty(globalThis.navigator, "onLine", { configurable: true, value: previousOnline });
|
||||
}
|
||||
});
|
||||
|
||||
test("preserves a newer draft written while a memo update is in flight", async () => {
|
||||
const previousOnline = globalThis.navigator?.onLine;
|
||||
if (globalThis.navigator) Object.defineProperty(globalThis.navigator, "onLine", { configurable: true, value: true });
|
||||
@@ -263,6 +317,26 @@ describe("web sync conflict recovery", () => {
|
||||
api.getMemo = originalGetMemo;
|
||||
});
|
||||
|
||||
test("explicitly discards an old conflict when its cloud note is gone", async () => {
|
||||
const scope = createLocalDataScope("https://demo.edgeever.org", "user-1");
|
||||
const memo = await createLocalMemo(scope, { notebookId: "inbox", title: "Deleted cloud note" });
|
||||
await localDb.syncQueue.put({
|
||||
id: getMemoUpdateQueueId(memo.id), kind: "memo.update", scope, memoId: memo.id,
|
||||
status: "conflict", payload: { memoId: memo.id }, attemptCount: 1,
|
||||
lastError: "conflict", nextAttemptAt: null, claimId: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
const original = api.getMemo;
|
||||
api.getMemo = async () => { throw new ApiRequestError("Not found", 404, "not_found"); };
|
||||
try {
|
||||
expect(await discardWebConflicts(scope)).toBe(1);
|
||||
expect(await localDb.syncQueue.get(getMemoUpdateQueueId(memo.id))).toBeUndefined();
|
||||
expect(await getLocalMemo(scope, memo.id)).toBeNull();
|
||||
} finally {
|
||||
api.getMemo = original;
|
||||
}
|
||||
});
|
||||
|
||||
test("uploads an offline resource and rewrites its memo reference", async () => {
|
||||
const previousCaches = globalThis.caches;
|
||||
const previousOnline = globalThis.navigator?.onLine;
|
||||
|
||||
@@ -50,13 +50,24 @@ export const getLocalActionQueueId = (scope: string, kind: LocalActionKind, enti
|
||||
export const queueLocalAction = async (scope: string, kind: LocalActionKind, entityId: string, payload: LocalActionPayload, memoId = entityId) => {
|
||||
const id = getLocalActionQueueId(scope, kind, entityId);
|
||||
const now = new Date().toISOString();
|
||||
const previous = kind === "memo.emptyTrash" ? await localDb.syncQueue.get(id) : null;
|
||||
const previousMemoIds = (previous?.payload as LocalActionPayload | undefined)?.memoIds;
|
||||
const queuedPayload = kind === "memo.emptyTrash" && previous?.kind === kind
|
||||
? {
|
||||
...payload,
|
||||
memoIds: [...new Set([
|
||||
...(Array.isArray(previousMemoIds) ? previousMemoIds : []),
|
||||
...(Array.isArray(payload.memoIds) ? payload.memoIds : []),
|
||||
])],
|
||||
}
|
||||
: payload;
|
||||
await localDb.syncQueue.put({
|
||||
id,
|
||||
kind,
|
||||
scope,
|
||||
memoId,
|
||||
status: "pending",
|
||||
payload,
|
||||
payload: queuedPayload,
|
||||
attemptCount: 0,
|
||||
lastError: null,
|
||||
lastErrorCode: null,
|
||||
@@ -267,12 +278,19 @@ export const discardWebConflicts = async (scope: string) => {
|
||||
}
|
||||
}
|
||||
let discarded = 0;
|
||||
const { putLocalMemo } = await import("@/lib/local-mirror");
|
||||
const { deleteLocalMemo, putLocalMemo } = await import("@/lib/local-mirror");
|
||||
for (const item of conflicts) {
|
||||
try {
|
||||
if (item.kind === "memo.update") {
|
||||
const remote = await api.getMemo(item.memoId, { includeDeleted: true });
|
||||
await putLocalMemo(scope, remote.memo);
|
||||
try {
|
||||
const remote = await api.getMemo(item.memoId, { includeDeleted: true });
|
||||
await putLocalMemo(scope, remote.memo);
|
||||
} catch (error) {
|
||||
if (!(error instanceof ApiRequestError) || error.status !== 404) throw error;
|
||||
// The user explicitly chose to discard this local draft, and the
|
||||
// cloud note was permanently removed on another sync pass.
|
||||
await deleteLocalMemo(scope, item.memoId, true);
|
||||
}
|
||||
await localDb.drafts.delete(item.memoId);
|
||||
}
|
||||
await localDb.syncQueue.delete(item.id);
|
||||
@@ -489,6 +507,13 @@ const isDraftCoveredByMemoUpdate = (item: SyncQueueItem, draft: LocalDraft | und
|
||||
JSON.stringify(draft.contentJson) === JSON.stringify(payload.contentJson);
|
||||
};
|
||||
|
||||
const deletedMemoIdsFromAction = (item: SyncQueueItem): string[] => {
|
||||
if (item.kind === "memo.delete") return [item.memoId];
|
||||
if (item.kind !== "memo.deleteBatch" && item.kind !== "memo.emptyTrash") return [];
|
||||
const memoIds = (item.payload as LocalActionPayload).memoIds;
|
||||
return Array.isArray(memoIds) ? memoIds.filter((id): id is string => typeof id === "string") : [];
|
||||
};
|
||||
|
||||
const acknowledgeClaimedQueueItem = (item: SyncQueueItem, result: SyncQueueResult): Promise<MemoUpdateAcknowledgement> =>
|
||||
localDb.transaction("rw", [localDb.syncQueue, localDb.drafts, localDb.memos], async () => {
|
||||
const current = await localDb.syncQueue.get(item.id);
|
||||
@@ -498,6 +523,18 @@ const acknowledgeClaimedQueueItem = (item: SyncQueueItem, result: SyncQueueResul
|
||||
|
||||
if (current?.claimId === item.claimId && current.status === "syncing") {
|
||||
await localDb.syncQueue.delete(item.id);
|
||||
// A successful delete supersedes older conflicted edits to those notes.
|
||||
// Keep the draft until the server has confirmed the delete, so a failed
|
||||
// delete still leaves the local conflict recoverable.
|
||||
for (const memoId of deletedMemoIdsFromAction(item)) {
|
||||
for (const queued of await localDb.syncQueue.where("memoId").equals(memoId).toArray()) {
|
||||
if (queued.kind !== "memo.update" || queued.status !== "conflict") continue;
|
||||
if (queued.scope && queued.scope !== item.scope) continue;
|
||||
if (queued.createdAt > item.createdAt) continue;
|
||||
await localDb.syncQueue.delete(queued.id);
|
||||
await localDb.drafts.delete(memoId);
|
||||
}
|
||||
}
|
||||
if (item.kind !== "memo.create") {
|
||||
const draft = await localDb.drafts.get(item.memoId);
|
||||
if (!draft || item.kind !== "memo.update" || isDraftCoveredByMemoUpdate(item, draft)) {
|
||||
|
||||
@@ -193,7 +193,50 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::memo::{resolve_custom_merge_title, resolve_sidecar_merge_markdown};
|
||||
use crate::sync::{apply_sync_changes, prepare_sync_bootstrap};
|
||||
use crate::sync::{apply_sync_changes, prepare_sync_bootstrap, sync_outbox_ack};
|
||||
|
||||
#[test]
|
||||
fn acknowledged_deletes_clear_only_older_conflicted_memo_edits() {
|
||||
for (kind, entity_id, payload) in [
|
||||
("memo.delete", "memo-a", json!({ "memoId": "memo-a" })),
|
||||
("memo.deleteBatch", "batch", json!({ "memoIds": ["memo-a"] })),
|
||||
("memo.emptyTrash", "trash", json!({ "memoIds": ["memo-a"] })),
|
||||
] {
|
||||
let database = Connection::open_in_memory().unwrap();
|
||||
database.execute_batch(
|
||||
"CREATE TABLE _edgeever_sidecar_outbox (
|
||||
id INTEGER PRIMARY KEY, kind TEXT NOT NULL, entity_id TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL, status TEXT NOT NULL, version INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO _edgeever_sidecar_outbox VALUES
|
||||
(1, 'memo.update', 'memo-a', '{}', 'conflict', 1),
|
||||
(2, 'memo.update', 'memo-b', '{}', 'conflict', 1);",
|
||||
).unwrap();
|
||||
database.execute(
|
||||
"INSERT INTO _edgeever_sidecar_outbox VALUES (3, ?1, ?2, ?3, 'pending', 1)",
|
||||
rusqlite::params![kind, entity_id, payload.to_string()],
|
||||
).unwrap();
|
||||
database.execute(
|
||||
"INSERT INTO _edgeever_sidecar_outbox VALUES (4, 'memo.update', 'memo-a', '{}', 'conflict', 1)",
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
let stale = sync_outbox_ack(&database, &json!({ "id": 3, "version": 0 })).unwrap();
|
||||
assert_eq!(stale["superseded"], true);
|
||||
let count: i64 = database.query_row(
|
||||
"SELECT COUNT(*) FROM _edgeever_sidecar_outbox WHERE status = 'conflict'",
|
||||
[], |row| row.get(0),
|
||||
).unwrap();
|
||||
assert_eq!(count, 3, "stale acknowledgement must preserve drafts");
|
||||
|
||||
sync_outbox_ack(&database, &json!({ "id": 3, "version": 1 })).unwrap();
|
||||
let remaining: Vec<i64> = database.prepare(
|
||||
"SELECT id FROM _edgeever_sidecar_outbox ORDER BY id"
|
||||
).unwrap().query_map([], |row| row.get(0)).unwrap()
|
||||
.collect::<Result<_, _>>().unwrap();
|
||||
assert_eq!(remaining, vec![2, 4], "{kind} must clear only the deleted memo's older conflict");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string_param_rejects_missing_or_non_string_values() {
|
||||
|
||||
@@ -224,6 +224,13 @@ pub(crate) fn empty_trash(database: &Connection) -> Result<Value, String> {
|
||||
let tx = database
|
||||
.unchecked_transaction()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let memo_ids: Vec<String> = tx
|
||||
.prepare("SELECT id FROM memos WHERE is_deleted = 1")
|
||||
.map_err(|e| e.to_string())?
|
||||
.query_map([], |row| row.get(0))
|
||||
.map_err(|e| e.to_string())?
|
||||
.collect::<Result<_, _>>()
|
||||
.map_err(|e| e.to_string())?;
|
||||
tx.execute(
|
||||
"DELETE FROM resources WHERE memo_id IN (SELECT id FROM memos WHERE is_deleted = 1)",
|
||||
[],
|
||||
@@ -233,7 +240,7 @@ pub(crate) fn empty_trash(database: &Connection) -> Result<Value, String> {
|
||||
.execute("DELETE FROM memos WHERE is_deleted = 1", [])
|
||||
.map_err(|e| e.to_string())?;
|
||||
tx.commit().map_err(|e| e.to_string())?;
|
||||
enqueue_change(database, "memo.emptyTrash", "trash", &json!({}))?;
|
||||
enqueue_change(database, "memo.emptyTrash", "trash", &json!({ "memoIds": memo_ids }))?;
|
||||
Ok(json!({ "ok": true, "deleted": deleted }))
|
||||
}
|
||||
|
||||
|
||||
@@ -160,11 +160,11 @@ pub(crate) fn sync_outbox_ack(database: &Connection, params: &Value) -> Result<V
|
||||
.get("id")
|
||||
.and_then(Value::as_i64)
|
||||
.ok_or_else(|| "Missing outbox id".to_owned())?;
|
||||
let (kind, entity_id, current_version): (String, String, i64) = database
|
||||
let (kind, entity_id, payload_json, current_version): (String, String, String, i64) = database
|
||||
.query_row(
|
||||
"SELECT kind, entity_id, version FROM _edgeever_sidecar_outbox WHERE id = ?1",
|
||||
"SELECT kind, entity_id, payload_json, version FROM _edgeever_sidecar_outbox WHERE id = ?1",
|
||||
[id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let requested_version = params.get("version").and_then(Value::as_i64);
|
||||
@@ -447,18 +447,40 @@ pub(crate) fn sync_outbox_ack(database: &Connection, params: &Value) -> Result<V
|
||||
}
|
||||
}
|
||||
}
|
||||
let tx = database
|
||||
.unchecked_transaction()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let deleted = if let Some(version) = requested_version {
|
||||
database
|
||||
tx
|
||||
.execute(
|
||||
"DELETE FROM _edgeever_sidecar_outbox WHERE id = ?1 AND version = ?2",
|
||||
rusqlite::params![id, version],
|
||||
)
|
||||
.map_err(|e| e.to_string())?
|
||||
} else {
|
||||
database
|
||||
tx
|
||||
.execute("DELETE FROM _edgeever_sidecar_outbox WHERE id = ?1", [id])
|
||||
.map_err(|e| e.to_string())?
|
||||
};
|
||||
if deleted == 1 && matches!(kind.as_str(), "memo.delete" | "memo.deleteBatch" | "memo.emptyTrash") {
|
||||
let payload: Value = serde_json::from_str(&payload_json).unwrap_or_else(|_| json!({}));
|
||||
let memo_ids: Vec<&str> = if kind == "memo.delete" {
|
||||
vec![entity_id.as_str()]
|
||||
} else {
|
||||
payload.get("memoIds").and_then(Value::as_array)
|
||||
.map(|ids| ids.iter().filter_map(Value::as_str).collect())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
for memo_id in memo_ids {
|
||||
// Delete only older conflicted edits after the server accepted the
|
||||
// delete. A failed delete must leave its local draft recoverable.
|
||||
tx.execute(
|
||||
"DELETE FROM _edgeever_sidecar_outbox WHERE kind = 'memo.update' AND entity_id = ?1 AND status = 'conflict' AND id < ?2",
|
||||
rusqlite::params![memo_id, id],
|
||||
).map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
tx.commit().map_err(|e| e.to_string())?;
|
||||
Ok(
|
||||
json!({ "ok": true, "superseded": deleted == 0, "memo": remote_memo, "notebook": remote_notebook, "template": remote_template }),
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user