243 lines
6.7 KiB
TypeScript
243 lines
6.7 KiB
TypeScript
import { DEFAULT_MEMO_TITLE } from "./content";
|
|
import type { MemoDetail, Notebook } from "./types";
|
|
|
|
export type SyncEntityType = "memo" | "notebook";
|
|
export type SyncOperation = "upsert" | "delete";
|
|
|
|
export type SyncChange = {
|
|
cursor: number;
|
|
entityType: SyncEntityType;
|
|
entityId: string;
|
|
operation: SyncOperation;
|
|
notebook: Notebook | null;
|
|
memo: MemoDetail | null;
|
|
};
|
|
|
|
export type SyncBootstrapResponse = {
|
|
notebooks: Notebook[];
|
|
memos: MemoDetail[];
|
|
snapshotCursor: number;
|
|
syncIdentity?: string;
|
|
totalCount: number;
|
|
nextAfterId: string | null;
|
|
};
|
|
|
|
export type SyncChangesResponse = {
|
|
changes: SyncChange[];
|
|
cursor: number;
|
|
hasMore: boolean;
|
|
/** Absent on older servers; clients must keep the current mirror when omitted. */
|
|
serverCursor?: number;
|
|
syncIdentity?: string;
|
|
};
|
|
|
|
export type SyncCursorState = {
|
|
cursor: number;
|
|
syncIdentity: string;
|
|
};
|
|
|
|
export type RemoteSyncCursorState = {
|
|
serverCursor?: number;
|
|
syncIdentity?: string;
|
|
};
|
|
|
|
export type SyncOutboxOperation =
|
|
| "memo.create"
|
|
| "memo.update"
|
|
| "memo.delete"
|
|
| "memo.restore"
|
|
| "notebook.create"
|
|
| "notebook.update"
|
|
| "notebook.delete";
|
|
|
|
export type SyncQueueStatus = "pending" | "syncing" | "conflict" | "error";
|
|
|
|
export type SyncQueueSummary = {
|
|
total: number;
|
|
pending: number;
|
|
syncing: number;
|
|
conflict: number;
|
|
error: number;
|
|
};
|
|
|
|
export type SyncRunResult = {
|
|
attempted: number;
|
|
synced: number;
|
|
failed: number;
|
|
conflicted: number;
|
|
};
|
|
|
|
export type SyncQueueStatusItem = {
|
|
status: SyncQueueStatus;
|
|
};
|
|
|
|
export type RetryableSyncQueueItem = SyncQueueStatusItem & {
|
|
nextAttemptAt: string | null;
|
|
};
|
|
|
|
export type MemoSyncBase = {
|
|
revision: number;
|
|
contentHash: string;
|
|
};
|
|
|
|
export type MemoSyncExpectedBase = {
|
|
expectedRevision: number;
|
|
expectedContentHash: string;
|
|
};
|
|
|
|
export const isMemoSyncBaseCurrent = (
|
|
current: MemoSyncBase,
|
|
expected: MemoSyncExpectedBase,
|
|
) => current.revision === expected.expectedRevision
|
|
&& current.contentHash === expected.expectedContentHash;
|
|
|
|
export const getMemoSyncBaseConflictDetails = (
|
|
current: MemoSyncBase,
|
|
expected: MemoSyncExpectedBase,
|
|
) => ({
|
|
expectedRevision: expected.expectedRevision,
|
|
currentRevision: current.revision,
|
|
expectedContentHash: expected.expectedContentHash,
|
|
currentContentHash: current.contentHash,
|
|
source: "offline_sync" as const,
|
|
});
|
|
|
|
export type SameDeviceMemoSyncRecovery = "ack" | "rebase" | "conflict";
|
|
|
|
const normalizeMemoSyncTitle = (value: unknown) => {
|
|
const title = typeof value === "string" ? value.trim() : "";
|
|
return title || DEFAULT_MEMO_TITLE;
|
|
};
|
|
|
|
const normalizeMemoSyncTags = (value: unknown) =>
|
|
Array.isArray(value) ? value.filter((tag): tag is string => typeof tag === "string") : [];
|
|
|
|
export const memoUpdatePayloadMatchesRemote = (
|
|
payload: {
|
|
title?: unknown;
|
|
tags?: unknown;
|
|
contentMarkdown?: unknown;
|
|
contentJson?: unknown;
|
|
},
|
|
remote: {
|
|
title?: string | null;
|
|
tags?: readonly string[];
|
|
contentMarkdown?: string;
|
|
contentJson?: unknown;
|
|
},
|
|
) => {
|
|
if (normalizeMemoSyncTitle(payload.title) !== normalizeMemoSyncTitle(remote.title)) {
|
|
return false;
|
|
}
|
|
const payloadTags = normalizeMemoSyncTags(payload.tags);
|
|
const remoteTags = normalizeMemoSyncTags(remote.tags);
|
|
if (payloadTags.length !== remoteTags.length || payloadTags.some((tag, index) => tag !== remoteTags[index])) {
|
|
return false;
|
|
}
|
|
const payloadMarkdown = typeof payload.contentMarkdown === "string" ? payload.contentMarkdown : "";
|
|
const remoteMarkdown = remote.contentMarkdown ?? "";
|
|
if (payloadMarkdown !== "" || remoteMarkdown !== "") {
|
|
return payloadMarkdown === remoteMarkdown;
|
|
}
|
|
return JSON.stringify(payload.contentJson ?? null) === JSON.stringify(remote.contentJson ?? null);
|
|
};
|
|
|
|
export const resolveSameDeviceMemoSyncRecovery = (input: {
|
|
current: MemoSyncBase;
|
|
expected: MemoSyncExpectedBase;
|
|
payloadMatchesRemote: boolean;
|
|
remoteProducedLocally: boolean;
|
|
}): SameDeviceMemoSyncRecovery => {
|
|
if (input.payloadMatchesRemote) {
|
|
return "ack";
|
|
}
|
|
if (input.expected.expectedRevision < input.current.revision && input.remoteProducedLocally) {
|
|
return "rebase";
|
|
}
|
|
return "conflict";
|
|
};
|
|
|
|
export const createEmptySyncQueueSummary = (): SyncQueueSummary => ({
|
|
total: 0,
|
|
pending: 0,
|
|
syncing: 0,
|
|
conflict: 0,
|
|
error: 0,
|
|
});
|
|
|
|
export const createEmptySyncRunResult = (): SyncRunResult => ({
|
|
attempted: 0,
|
|
synced: 0,
|
|
failed: 0,
|
|
conflicted: 0,
|
|
});
|
|
|
|
export const summarizeSyncQueue = (
|
|
items: ReadonlyArray<SyncQueueStatusItem>
|
|
): SyncQueueSummary => items.reduce((summary, item) => {
|
|
summary.total += 1;
|
|
summary[item.status] += 1;
|
|
return summary;
|
|
}, createEmptySyncQueueSummary());
|
|
|
|
export const getSyncRetryDelayMs = (attemptCount: number) =>
|
|
Math.min(5 * 60_000, 2 ** Math.min(Math.max(0, attemptCount), 6) * 1000);
|
|
|
|
export const getSyncRetryAt = (attemptCount: number, now = Date.now()) =>
|
|
new Date(now + getSyncRetryDelayMs(attemptCount)).toISOString();
|
|
|
|
export const getNextSyncQueueRetryDelay = (
|
|
items: ReadonlyArray<RetryableSyncQueueItem>,
|
|
now = Date.now(),
|
|
minimumDelayMs = 250
|
|
): number | null => {
|
|
const retryTimes = items
|
|
.filter((item) => item.status === "pending" || item.status === "error" || item.status === "syncing")
|
|
.map((item) => (item.nextAttemptAt ? Date.parse(item.nextAttemptAt) : now))
|
|
.filter(Number.isFinite);
|
|
|
|
if (retryTimes.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return Math.max(minimumDelayMs, Math.min(...retryTimes) - now);
|
|
};
|
|
|
|
export const hasSyncCursorRewound = (localCursor: number, serverCursor?: number) =>
|
|
typeof serverCursor === "number"
|
|
&& Number.isFinite(serverCursor)
|
|
&& serverCursor < localCursor;
|
|
|
|
export const hasSyncIdentityChanged = (localIdentity: string, serverIdentity?: string) =>
|
|
typeof serverIdentity === "string"
|
|
&& serverIdentity.length > 0
|
|
&& serverIdentity !== localIdentity;
|
|
|
|
export const hasSyncStateReset = (
|
|
local: SyncCursorState,
|
|
remote: RemoteSyncCursorState,
|
|
) => hasSyncCursorRewound(local.cursor, remote.serverCursor)
|
|
|| hasSyncIdentityChanged(local.syncIdentity, remote.syncIdentity);
|
|
|
|
export const isSyncMetadataInitialized = (
|
|
cursorValue: string | null | undefined,
|
|
identityValue: string | null | undefined,
|
|
) => Boolean(identityValue?.trim())
|
|
&& typeof cursorValue === "string"
|
|
&& cursorValue.trim().length > 0
|
|
&& Number.isFinite(Number(cursorValue));
|
|
|
|
export const splitSyncBootstrapWriteBatches = <T>(
|
|
items: readonly T[],
|
|
batchSize: number,
|
|
): T[][] => {
|
|
const normalizedBatchSize = Math.max(1, Math.floor(batchSize));
|
|
if (items.length === 0) {
|
|
return [[]];
|
|
}
|
|
return Array.from(
|
|
{ length: Math.ceil(items.length / normalizedBatchSize) },
|
|
(_, index) => items.slice(index * normalizedBatchSize, (index + 1) * normalizedBatchSize),
|
|
);
|
|
};
|