Files
edgeever/apps/mobile/src/lib/sync-queue.ts
T

565 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AsyncStorage from "@react-native-async-storage/async-storage";
import { ApiRequestError, type createEdgeEverClient } from "@edgeever/client";
import {
createEmptySyncQueueSummary,
createEmptySyncRunResult,
formatLocalDraftClipboardText,
getNextSyncQueueRetryDelay,
getSyncRetryAt,
isMemoSyncBaseCurrent,
summarizeSyncQueue,
type MemoDetail,
type SyncQueueSummary,
type SyncRunResult,
} from "@edgeever/shared";
import { readMobileMemoDraft } from "./mobile-drafts";
const LEGACY_SYNC_QUEUE_KEY = "edgeever.mobile.syncQueue.v1";
const SYNC_QUEUE_KEY_PREFIX = "edgeever.mobile.syncQueue.v2";
export type MobileMemoUpdateSyncPayload = {
memoId: string;
expectedRevision: number;
expectedContentHash: string;
title: string;
contentMarkdown: string;
notebookId: string;
tags: string[];
};
export type MobileMemoCreateSyncPayload = {
memoId: string;
title: string;
contentMarkdown: string;
notebookId: string;
tags: string[];
createdAt: string;
};
type MobileSyncQueueItemBase = {
id: string;
memoId: string;
status: "pending" | "syncing" | "conflict" | "error";
attemptCount: number;
lastError: string | null;
nextAttemptAt: string | null;
createdAt: string;
updatedAt: string;
version: number;
};
export type MobileMemoUpdateSyncQueueItem = MobileSyncQueueItemBase & {
kind: "memo.update";
payload: MobileMemoUpdateSyncPayload;
};
export type MobileMemoCreateSyncQueueItem = MobileSyncQueueItemBase & {
kind: "memo.create";
payload: MobileMemoCreateSyncPayload;
};
export type MobileSyncQueueItem = MobileMemoUpdateSyncQueueItem | MobileMemoCreateSyncQueueItem;
let syncQueueWriteChain: Promise<void> = Promise.resolve();
export type MobileSyncQueueSummary = SyncQueueSummary;
export type MobileSyncRunResult = SyncRunResult;
export const emptyMobileSyncQueueSummary = createEmptySyncQueueSummary;
export const getMobileMemoUpdateQueueId = (memoId: string) => `memo.update:${memoId}`;
export const getMobileMemoCreateQueueId = (memoId: string) => `memo.create:${memoId}`;
export const queueMobileMemoCreate = async (scope: string, payload: MobileMemoCreateSyncPayload) => {
const now = new Date().toISOString();
const id = getMobileMemoCreateQueueId(payload.memoId);
await mutateMobileSyncQueue(scope, (items) => {
const existing = items.find((item) => item.id === id);
const nextItem: MobileMemoCreateSyncQueueItem = {
id,
kind: "memo.create",
memoId: payload.memoId,
status: "pending",
payload,
attemptCount: existing?.attemptCount ?? 0,
lastError: null,
nextAttemptAt: null,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
version: (existing?.version ?? 0) + 1,
};
return [nextItem, ...items.filter((item) => item.id !== id)];
});
return summarizeMobileSyncQueue(await readMobileSyncQueue(scope));
};
export const queueMobileMemoUpdate = async (scope: string, payload: MobileMemoUpdateSyncPayload) => {
const now = new Date().toISOString();
const id = getMobileMemoUpdateQueueId(payload.memoId);
await mutateMobileSyncQueue(scope, (items) => {
const pendingCreate = items.find((item): item is MobileMemoCreateSyncQueueItem => item.kind === "memo.create" && item.memoId === payload.memoId);
if (pendingCreate) {
return items.map((item) => item.id === pendingCreate.id ? {
...pendingCreate,
status: "pending",
payload: {
...pendingCreate.payload,
title: payload.title,
contentMarkdown: payload.contentMarkdown,
notebookId: payload.notebookId,
tags: payload.tags,
},
lastError: null,
nextAttemptAt: null,
updatedAt: now,
version: pendingCreate.version + 1,
} : item);
}
const existing = items.find((item) => item.id === id);
const nextItem: MobileMemoUpdateSyncQueueItem = {
id,
kind: "memo.update",
memoId: payload.memoId,
status: "pending",
payload,
attemptCount: existing?.attemptCount ?? 0,
lastError: null,
nextAttemptAt: null,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
version: (existing?.version ?? 0) + 1,
};
return [nextItem, ...items.filter((item) => item.id !== id)];
});
return summarizeMobileSyncQueue(await readMobileSyncQueue(scope));
};
export const loadMobileSyncQueueSummary = async (scope: string) => summarizeMobileSyncQueue(await readMobileSyncQueue(scope));
export const getMobileSyncRetryDelay = async (scope: string) =>
getNextSyncQueueRetryDelay(await readMobileSyncQueue(scope));
export const listMobileSyncQueueItems = async (scope: string) =>
(await readMobileSyncQueue(scope)).sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
export const deleteMobileSyncQueueItem = async (scope: string, id: string) => {
await removeMobileSyncQueueItem(scope, id);
return loadMobileSyncQueueSummary(scope);
};
/** Drop a successful online update so a stale outbox entry cannot re-apply older content. */
export const clearMobileMemoUpdateQueueItem = async (scope: string, memoId: string) => {
await removeMobileSyncQueueItem(scope, getMobileMemoUpdateQueueId(memoId));
return loadMobileSyncQueueSummary(scope);
};
/**
* Clear backoff so the next sync pass retries immediately.
* Conflict items stay conflicted until the user resolves them.
*/
export const armMobileSyncQueueImmediateRetry = async (scope: string) => {
const now = new Date().toISOString();
await mutateMobileSyncQueue(scope, (items) =>
items.map((item) => {
if (item.status === "conflict") {
return item;
}
if (item.status === "pending" || item.status === "error" || item.status === "syncing") {
return {
...item,
status: "pending",
nextAttemptAt: null,
updatedAt: now,
};
}
return item;
})
);
return loadMobileSyncQueueSummary(scope);
};
export const markMobileMemoUpdateConflict = async (scope: string, memoId: string, lastError: string) => {
const id = getMobileMemoUpdateQueueId(memoId);
const now = new Date().toISOString();
await mutateMobileSyncQueue(scope, (items) =>
items.map((item) => {
if (item.id !== id && !(item.kind === "memo.create" && item.memoId === memoId)) {
return item;
}
return {
...item,
status: "conflict",
lastError,
nextAttemptAt: null,
updatedAt: now,
};
})
);
return loadMobileSyncQueueSummary(scope);
};
export const markMobileMemoUpdateError = async (scope: string, memoId: string, lastError: string) => {
const id = getMobileMemoUpdateQueueId(memoId);
const now = new Date().toISOString();
await mutateMobileSyncQueue(scope, (items) =>
items.map((item) => {
if (item.id !== id && !(item.kind === "memo.create" && item.memoId === memoId)) {
return item;
}
return {
...item,
status: "error",
lastError,
nextAttemptAt: null,
updatedAt: now,
};
})
);
return loadMobileSyncQueueSummary(scope);
};
export const getMobileSyncErrorMessage = (error: unknown) =>
error instanceof Error ? error.message : "Sync failed";
export const isMobileSyncConflictError = (error: unknown) => {
if (!(error instanceof ApiRequestError)) {
return false;
}
if (error.status === 409) {
return true;
}
const code = (error.code ?? "").toLowerCase();
return code.includes("conflict");
};
/** Drop every create/update queue entry for a memo (used when discarding local-only notes). */
export const cancelMobileMemoQueueItems = async (scope: string, memoId: string) => {
await mutateMobileSyncQueue(scope, (items) => items.filter((item) => item.memoId !== memoId));
return loadMobileSyncQueueSummary(scope);
};
/**
* Discard a single note's conflicted local queue item and return the
* authoritative cloud memo so the UI can rehydrate cleanly.
*/
export const discardMobileMemoConflict = async (
client: ReturnType<typeof createEdgeEverClient>,
scope: string,
memoId: string,
) => {
const remote = await client.getMemo(memoId, { includeDeleted: true });
const queueId = getMobileMemoUpdateQueueId(memoId);
const items = await readMobileSyncQueue(scope);
for (const item of items) {
if (item.memoId !== memoId) {
continue;
}
if (item.id === queueId || item.status === "conflict") {
await removeMobileSyncQueueItem(scope, item.id);
}
}
return remote.memo;
};
/** Resolve the best local draft snapshot to copy when a conflict is shown. */
export const getMobileConflictDraftClipboardText = async (scope: string, memoId: string) => {
const [queued, draft] = await Promise.all([
readMobileSyncQueue(scope).then((items) =>
items.find((item) => item.memoId === memoId && item.kind === "memo.update")
),
readMobileMemoDraft(memoId),
]);
if (queued && queued.kind === "memo.update") {
return formatLocalDraftClipboardText({
title: queued.payload.title,
tags: queued.payload.tags,
contentMarkdown: queued.payload.contentMarkdown,
});
}
if (draft) {
return formatLocalDraftClipboardText({
title: draft.title,
tags: draft.tagsText
.split(/[,,]/u)
.map((tag) => tag.trim())
.filter(Boolean),
contentMarkdown: draft.contentMarkdown,
});
}
return null;
};
export const syncMobileQueuedChanges = async (
client: ReturnType<typeof createEdgeEverClient>,
scope: string,
options: {
force?: boolean;
onSynced?: (memo: MemoDetail, item: MobileSyncQueueItem) => void | Promise<void>;
} = {}
): Promise<MobileSyncRunResult> => {
if (options.force) {
await armMobileSyncQueueImmediateRetry(scope);
}
const result = createEmptySyncRunResult();
const now = new Date();
const items = (await readMobileSyncQueue(scope))
.filter((item) => item.status === "pending" || item.status === "error" || item.status === "syncing")
.filter((item) => options.force || !item.nextAttemptAt || new Date(item.nextAttemptAt) <= now)
.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
for (const item of items) {
const itemVersion = item.version ?? 1;
result.attempted += 1;
await updateMobileSyncQueueItem(scope, item.id, {
status: "syncing",
updatedAt: new Date().toISOString(),
}, itemVersion);
try {
const memo = await syncMobileQueueItem(client, item);
const removed = await removeMobileSyncQueueItem(scope, item.id, itemVersion);
if (removed) {
await options.onSynced?.(memo, item);
} else if (item.kind === "memo.create") {
const promoted = await promoteQueuedMemoCreate(scope, item.id, itemVersion, memo);
if (promoted) {
await options.onSynced?.(memo, item);
} else {
// User cancelled the offline create while it was in flight. The local
// row is already gone; soft-delete the remote orphan so it does not
// reappear on the next mirror sync.
try {
await client.deleteMemo(memo.id, { permanent: false });
} catch {
// Best-effort cleanup; a later full sync can still reconcile.
}
}
} else {
await rebaseQueuedMemoUpdate(scope, item.id, itemVersion, memo);
}
result.synced += 1;
} catch (error) {
const status = isMobileSyncConflictError(error) ? "conflict" : "error";
const attemptCount = item.attemptCount + 1;
await updateMobileSyncQueueItem(scope, item.id, {
status,
attemptCount,
lastError: getMobileSyncErrorMessage(error),
nextAttemptAt: status === "error" ? getSyncRetryAt(attemptCount) : null,
updatedAt: new Date().toISOString(),
}, itemVersion);
if (status === "conflict") {
result.conflicted += 1;
} else {
result.failed += 1;
}
}
}
return result;
};
export const shouldQueueMobileMemoSaveError = (error: unknown) => {
if (error instanceof ApiRequestError) {
return error.status === 0 || error.status === 408 || error.status === 429 || error.status >= 500;
}
const message = getMobileSyncErrorMessage(error).toLowerCase();
return error instanceof TypeError
|| message.includes("network")
|| message.includes("failed to fetch")
|| message.includes("network request failed")
|| message.includes("timeout");
};
const syncMobileQueueItem = async (client: ReturnType<typeof createEdgeEverClient>, item: MobileSyncQueueItem) => {
if (item.kind === "memo.create") {
const response = await client.createMemo({
notebookId: item.payload.notebookId,
title: item.payload.title,
contentMarkdown: item.payload.contentMarkdown,
tags: item.payload.tags,
createdAt: item.payload.createdAt,
updatedAt: item.updatedAt,
});
return response.memo;
}
const { editSession } = await client.createMemoEditSession(item.memoId);
if (!isMemoSyncBaseCurrent(
{ revision: editSession.baseRevision, contentHash: editSession.baseContentHash },
item.payload,
)) {
throw new ApiRequestError("Note changed before the offline draft could sync.", 409, "revision_conflict");
}
const response = await client.updateMemo(item.memoId, {
expectedRevision: item.payload.expectedRevision,
expectedContentHash: item.payload.expectedContentHash,
editSessionId: editSession.id,
title: item.payload.title,
contentMarkdown: item.payload.contentMarkdown,
notebookId: item.payload.notebookId,
tags: item.payload.tags,
});
return response.memo;
};
const readMobileSyncQueue = async (scope: string): Promise<MobileSyncQueueItem[]> => {
const storageKey = getSyncQueueStorageKey(scope);
let rawValue = await AsyncStorage.getItem(storageKey);
if (!rawValue) {
const legacyValue = await AsyncStorage.getItem(LEGACY_SYNC_QUEUE_KEY);
if (legacyValue) {
rawValue = legacyValue;
await AsyncStorage.setItem(storageKey, legacyValue);
await AsyncStorage.removeItem(LEGACY_SYNC_QUEUE_KEY);
}
}
if (!rawValue) {
return [];
}
try {
const parsed = JSON.parse(rawValue);
return Array.isArray(parsed) ? parsed.filter(isMobileSyncQueueItem) : [];
} catch {
return [];
}
};
const writeMobileSyncQueue = (scope: string, items: MobileSyncQueueItem[]) =>
AsyncStorage.setItem(getSyncQueueStorageKey(scope), JSON.stringify(items));
const updateMobileSyncQueueItem = async (scope: string, id: string, patch: Partial<MobileSyncQueueItemBase>, expectedVersion?: number) => {
let updated = false;
await mutateMobileSyncQueue(scope, (items) =>
items.map((item) => {
if (item.id !== id || (expectedVersion !== undefined && (item.version ?? 1) !== expectedVersion)) {
return item;
}
updated = true;
return { ...item, ...patch };
})
);
return updated;
};
const removeMobileSyncQueueItem = async (scope: string, id: string, expectedVersion?: number) => {
let removed = false;
await mutateMobileSyncQueue(scope, (items) =>
items.filter((item) => {
if (item.id !== id || (expectedVersion !== undefined && (item.version ?? 1) !== expectedVersion)) {
return true;
}
removed = true;
return false;
})
);
return removed;
};
const rebaseQueuedMemoUpdate = async (scope: string, id: string, syncedVersion: number, memo: MemoDetail) => {
await mutateMobileSyncQueue(scope, (items) =>
items.map((item) => {
if (item.id !== id || item.kind !== "memo.update" || (item.version ?? 1) <= syncedVersion) {
return item;
}
return {
...item,
payload: {
...item.payload,
expectedRevision: memo.revision,
expectedContentHash: memo.contentHash,
},
status: "pending",
lastError: null,
nextAttemptAt: null,
updatedAt: new Date().toISOString(),
};
})
);
};
const promoteQueuedMemoCreate = async (
scope: string,
id: string,
syncedVersion: number,
memo: MemoDetail
): Promise<boolean> => {
let promoted = false;
await mutateMobileSyncQueue(scope, (items) => {
const current = items.find((item) => item.id === id);
if (!current || current.kind !== "memo.create" || current.version <= syncedVersion) {
return items;
}
promoted = true;
const now = new Date().toISOString();
const promotedItem: MobileMemoUpdateSyncQueueItem = {
id: getMobileMemoUpdateQueueId(memo.id),
kind: "memo.update",
memoId: memo.id,
status: "pending",
payload: {
memoId: memo.id,
expectedRevision: memo.revision,
expectedContentHash: memo.contentHash,
title: current.payload.title,
contentMarkdown: current.payload.contentMarkdown,
notebookId: current.payload.notebookId,
tags: current.payload.tags,
},
attemptCount: 0,
lastError: null,
nextAttemptAt: null,
createdAt: current.createdAt,
updatedAt: now,
version: 1,
};
return [promotedItem, ...items.filter((item) => item.id !== id && item.id !== promotedItem.id)];
});
return promoted;
};
const mutateMobileSyncQueue = async (scope: string, mutate: (items: MobileSyncQueueItem[]) => MobileSyncQueueItem[]) => {
const operation = syncQueueWriteChain.then(async () => {
const items = await readMobileSyncQueue(scope);
await writeMobileSyncQueue(scope, mutate(items));
});
syncQueueWriteChain = operation.catch(() => undefined);
await operation;
};
const getSyncQueueStorageKey = (scope: string) => `${SYNC_QUEUE_KEY_PREFIX}:${encodeURIComponent(scope.trim().toLowerCase())}`;
const summarizeMobileSyncQueue = (items: MobileSyncQueueItem[]) =>
summarizeSyncQueue(items);
const isMobileSyncQueueItem = (value: unknown): value is MobileSyncQueueItem => {
if (!value || typeof value !== "object") {
return false;
}
const item = value as Partial<MobileSyncQueueItem>;
return (item.kind === "memo.update" || item.kind === "memo.create") && typeof item.id === "string" && typeof item.memoId === "string" && Boolean(item.payload);
};