Files
edgeever/packages/shared/src/sync.ts
T

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),
);
};