feat(sync): implement lww conflict and tombstone handling
This commit is contained in:
@@ -1,10 +1,18 @@
|
|||||||
import "reflect-metadata";
|
import "reflect-metadata";
|
||||||
import { ValidationPipe } from "@nestjs/common";
|
import { ValidationPipe } from "@nestjs/common";
|
||||||
import { NestFactory } from "@nestjs/core";
|
import { NestFactory } from "@nestjs/core";
|
||||||
|
import type { NestExpressApplication } from "@nestjs/platform-express";
|
||||||
import { AppModule } from "./app.module";
|
import { AppModule } from "./app.module";
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||||
|
const bodyLimit = process.env.API_BODY_LIMIT ?? "8mb";
|
||||||
|
|
||||||
|
app.useBodyParser("json", { limit: bodyLimit });
|
||||||
|
app.useBodyParser("urlencoded", {
|
||||||
|
extended: true,
|
||||||
|
limit: bodyLimit
|
||||||
|
});
|
||||||
app.enableCors({
|
app.enableCors({
|
||||||
origin: true,
|
origin: true,
|
||||||
credentials: true
|
credentials: true
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export class SyncPushOperationDto {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(50000)
|
@MaxLength(5000000)
|
||||||
payload?: string;
|
payload?: string;
|
||||||
|
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
getLocalSyncState
|
getLocalSyncState
|
||||||
} from "@/services/local-sync-repo";
|
} from "@/services/local-sync-repo";
|
||||||
import type { WebSession } from "@/services/session-storage";
|
import type { WebSession } from "@/services/session-storage";
|
||||||
|
import { applyPendingRemoteOperations } from "@/services/sync-merge";
|
||||||
import { runSyncWorkerCycle } from "@/services/sync-worker";
|
import { runSyncWorkerCycle } from "@/services/sync-worker";
|
||||||
|
|
||||||
const PERIODIC_SYNC_INTERVAL_MS = 30_000;
|
const PERIODIC_SYNC_INTERVAL_MS = 30_000;
|
||||||
@@ -70,6 +71,7 @@ export function useSyncEngine(session: WebSession | null): {
|
|||||||
|
|
||||||
const retryAttemptRef = useRef(0);
|
const retryAttemptRef = useRef(0);
|
||||||
const runningRef = useRef(false);
|
const runningRef = useRef(false);
|
||||||
|
const mergeRunningRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLastSyncedAt(storedSyncState?.lastSyncedAt ?? null);
|
setLastSyncedAt(storedSyncState?.lastSyncedAt ?? null);
|
||||||
@@ -117,6 +119,37 @@ export function useSyncEngine(session: WebSession | null): {
|
|||||||
void runCycle();
|
void runCycle();
|
||||||
}, [runCycle]);
|
}, [runCycle]);
|
||||||
|
|
||||||
|
const runMerge = useCallback(async () => {
|
||||||
|
if (!userId || mergeRunningRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mergeRunningRef.current = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await applyPendingRemoteOperations(userId);
|
||||||
|
|
||||||
|
if (!runningRef.current) {
|
||||||
|
setPhase((currentPhase) => {
|
||||||
|
if (!window.navigator.onLine) {
|
||||||
|
return "offline";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPhase === "backoff") {
|
||||||
|
return currentPhase;
|
||||||
|
}
|
||||||
|
|
||||||
|
return blockedCount > 0 ? "attention" : "idle";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setLastError(getErrorMessage(error));
|
||||||
|
setPhase("attention");
|
||||||
|
} finally {
|
||||||
|
mergeRunningRef.current = false;
|
||||||
|
}
|
||||||
|
}, [blockedCount, userId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleOnline(): void {
|
function handleOnline(): void {
|
||||||
setIsOnline(true);
|
setIsOnline(true);
|
||||||
@@ -190,6 +223,14 @@ export function useSyncEngine(session: WebSession | null): {
|
|||||||
};
|
};
|
||||||
}, [isOnline, nextRetryAt, runCycle]);
|
}, [isOnline, nextRetryAt, runCycle]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userId || pendingRemoteCount === 0 || runningRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void runMerge();
|
||||||
|
}, [pendingRemoteCount, runMerge, userId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
setLastError(null);
|
setLastError(null);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export type LocalTaskRecord = {
|
|||||||
priority: LocalTaskPriority;
|
priority: LocalTaskPriority;
|
||||||
status: LocalTaskStatus;
|
status: LocalTaskStatus;
|
||||||
ddlAt: number | null;
|
ddlAt: number | null;
|
||||||
|
version: number;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
deletedAt: number | null;
|
deletedAt: number | null;
|
||||||
@@ -97,6 +98,25 @@ class TodoLocalDb extends Dexie {
|
|||||||
sync_inbox: "&opId,userId,entityId,serverTs,appliedAt"
|
sync_inbox: "&opId,userId,entityId,serverTs,appliedAt"
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.version(4)
|
||||||
|
.stores({
|
||||||
|
tasks: "&id,userId,status,priority,ddlAt,updatedAt,deletedAt",
|
||||||
|
op_logs: "&opId,entityId,entityType,action,clientTs,syncedAt",
|
||||||
|
task_drafts: "&taskId,userId,updatedAt",
|
||||||
|
sync_states: "&userId,updatedAt,lastSyncedAt",
|
||||||
|
sync_inbox: "&opId,userId,entityId,serverTs,appliedAt"
|
||||||
|
})
|
||||||
|
.upgrade(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.table("tasks")
|
||||||
|
.toCollection()
|
||||||
|
.modify((task: Partial<LocalTaskRecord>) => {
|
||||||
|
if (typeof task.version !== "number") {
|
||||||
|
task.version = 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
this.tasks = this.table("tasks");
|
this.tasks = this.table("tasks");
|
||||||
this.opLogs = this.table("op_logs");
|
this.opLogs = this.table("op_logs");
|
||||||
this.taskDrafts = this.table("task_drafts");
|
this.taskDrafts = this.table("task_drafts");
|
||||||
|
|||||||
@@ -118,6 +118,49 @@ export async function enqueueRemoteSyncOperations(
|
|||||||
return records.length;
|
return records.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listPendingRemoteOperations(
|
||||||
|
userId: string,
|
||||||
|
limit = 100
|
||||||
|
): Promise<LocalSyncInboxRecord[]> {
|
||||||
|
const records = await localDb.syncInbox.where("userId").equals(userId).toArray();
|
||||||
|
|
||||||
|
return records
|
||||||
|
.filter((record) => record.appliedAt === null)
|
||||||
|
.sort((left, right) => {
|
||||||
|
if (left.serverTs !== right.serverTs) {
|
||||||
|
return left.serverTs - right.serverTs;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (left.clientTs !== right.clientTs) {
|
||||||
|
return left.clientTs - right.clientTs;
|
||||||
|
}
|
||||||
|
|
||||||
|
return left.opId.localeCompare(right.opId);
|
||||||
|
})
|
||||||
|
.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markRemoteOperationsApplied(
|
||||||
|
opIds: string[],
|
||||||
|
appliedAt: number
|
||||||
|
): Promise<void> {
|
||||||
|
if (opIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = await localDb.syncInbox.bulkGet(opIds);
|
||||||
|
const nextRecords = records
|
||||||
|
.filter((record): record is LocalSyncInboxRecord => record !== undefined)
|
||||||
|
.map((record) => ({
|
||||||
|
...record,
|
||||||
|
appliedAt
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (nextRecords.length > 0) {
|
||||||
|
await localDb.syncInbox.bulkPut(nextRecords);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function countPendingRemoteOperations(userId: string): Promise<number> {
|
export async function countPendingRemoteOperations(userId: string): Promise<number> {
|
||||||
const records = await localDb.syncInbox.where("userId").equals(userId).toArray();
|
const records = await localDb.syncInbox.where("userId").equals(userId).toArray();
|
||||||
return records.filter((record) => record.appliedAt === null).length;
|
return records.filter((record) => record.appliedAt === null).length;
|
||||||
|
|||||||
@@ -24,6 +24,21 @@ export type UpdateLocalTaskInput = {
|
|||||||
ddlAt?: number | null;
|
ddlAt?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SyncTaskPayload = {
|
||||||
|
id?: string;
|
||||||
|
userId?: string;
|
||||||
|
title: string;
|
||||||
|
contentJson: string | null;
|
||||||
|
contentText?: string | null;
|
||||||
|
priority: LocalTaskPriority;
|
||||||
|
status: LocalTaskStatus;
|
||||||
|
ddlAt: number | null;
|
||||||
|
version: number;
|
||||||
|
createdAt?: number;
|
||||||
|
updatedAt: number;
|
||||||
|
deletedAt?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
function resolveDeviceId(): string {
|
function resolveDeviceId(): string {
|
||||||
const savedDeviceId = window.localStorage.getItem(DEVICE_ID_STORAGE_KEY);
|
const savedDeviceId = window.localStorage.getItem(DEVICE_ID_STORAGE_KEY);
|
||||||
if (savedDeviceId) {
|
if (savedDeviceId) {
|
||||||
@@ -54,6 +69,18 @@ function createOpLogRecord(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createSyncTaskPayload(payload: SyncTaskPayload): string {
|
||||||
|
const nextPayload: Record<string, unknown> = {
|
||||||
|
...payload
|
||||||
|
};
|
||||||
|
|
||||||
|
if (payload.contentJson !== null) {
|
||||||
|
delete nextPayload.contentText;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(nextPayload);
|
||||||
|
}
|
||||||
|
|
||||||
export async function listLocalTasksByUser(userId: string): Promise<LocalTaskRecord[]> {
|
export async function listLocalTasksByUser(userId: string): Promise<LocalTaskRecord[]> {
|
||||||
const tasks = await localDb.tasks.where("userId").equals(userId).toArray();
|
const tasks = await localDb.tasks.where("userId").equals(userId).toArray();
|
||||||
return tasks
|
return tasks
|
||||||
@@ -81,12 +108,30 @@ export async function createLocalTask(input: CreateLocalTaskInput): Promise<Loca
|
|||||||
priority: "MEDIUM",
|
priority: "MEDIUM",
|
||||||
status: "TODO",
|
status: "TODO",
|
||||||
ddlAt: null,
|
ddlAt: null,
|
||||||
|
version: 1,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
deletedAt: null
|
deletedAt: null
|
||||||
};
|
};
|
||||||
|
|
||||||
const opLog = createOpLogRecord(task.id, "CREATE", JSON.stringify(task));
|
const opLog = createOpLogRecord(
|
||||||
|
task.id,
|
||||||
|
"CREATE",
|
||||||
|
createSyncTaskPayload({
|
||||||
|
id: task.id,
|
||||||
|
userId: task.userId,
|
||||||
|
title: task.title,
|
||||||
|
contentJson: task.contentJson,
|
||||||
|
contentText: task.contentText,
|
||||||
|
priority: task.priority,
|
||||||
|
status: task.status,
|
||||||
|
ddlAt: task.ddlAt,
|
||||||
|
version: task.version,
|
||||||
|
createdAt: task.createdAt,
|
||||||
|
updatedAt: task.updatedAt,
|
||||||
|
deletedAt: task.deletedAt
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
await localDb.transaction("rw", localDb.tasks, localDb.opLogs, async () => {
|
await localDb.transaction("rw", localDb.tasks, localDb.opLogs, async () => {
|
||||||
await localDb.tasks.add(task);
|
await localDb.tasks.add(task);
|
||||||
@@ -104,6 +149,7 @@ export async function updateLocalTask(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextVersion = currentTask.version + 1;
|
||||||
const nextTask: LocalTaskRecord = {
|
const nextTask: LocalTaskRecord = {
|
||||||
...currentTask,
|
...currentTask,
|
||||||
title: input.title !== undefined ? input.title.trim() || "未命名任务" : currentTask.title,
|
title: input.title !== undefined ? input.title.trim() || "未命名任务" : currentTask.title,
|
||||||
@@ -112,19 +158,21 @@ export async function updateLocalTask(
|
|||||||
priority: input.priority ?? currentTask.priority,
|
priority: input.priority ?? currentTask.priority,
|
||||||
status: input.status ?? currentTask.status,
|
status: input.status ?? currentTask.status,
|
||||||
ddlAt: input.ddlAt !== undefined ? input.ddlAt : currentTask.ddlAt,
|
ddlAt: input.ddlAt !== undefined ? input.ddlAt : currentTask.ddlAt,
|
||||||
|
version: nextVersion,
|
||||||
updatedAt: Date.now()
|
updatedAt: Date.now()
|
||||||
};
|
};
|
||||||
|
|
||||||
const opLog = createOpLogRecord(
|
const opLog = createOpLogRecord(
|
||||||
nextTask.id,
|
nextTask.id,
|
||||||
"UPDATE",
|
"UPDATE",
|
||||||
JSON.stringify({
|
createSyncTaskPayload({
|
||||||
title: nextTask.title,
|
title: nextTask.title,
|
||||||
contentText: nextTask.contentText,
|
|
||||||
contentJson: nextTask.contentJson,
|
contentJson: nextTask.contentJson,
|
||||||
|
contentText: nextTask.contentText,
|
||||||
priority: nextTask.priority,
|
priority: nextTask.priority,
|
||||||
status: nextTask.status,
|
status: nextTask.status,
|
||||||
ddlAt: nextTask.ddlAt,
|
ddlAt: nextTask.ddlAt,
|
||||||
|
version: nextTask.version,
|
||||||
updatedAt: nextTask.updatedAt
|
updatedAt: nextTask.updatedAt
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -144,13 +192,23 @@ export async function deleteLocalTask(id: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const deletedAt = Date.now();
|
const deletedAt = Date.now();
|
||||||
|
const nextVersion = currentTask.version + 1;
|
||||||
const nextTask: LocalTaskRecord = {
|
const nextTask: LocalTaskRecord = {
|
||||||
...currentTask,
|
...currentTask,
|
||||||
|
version: nextVersion,
|
||||||
deletedAt,
|
deletedAt,
|
||||||
updatedAt: deletedAt
|
updatedAt: deletedAt
|
||||||
};
|
};
|
||||||
|
|
||||||
const opLog = createOpLogRecord(id, "DELETE", JSON.stringify({ deletedAt }));
|
const opLog = createOpLogRecord(
|
||||||
|
id,
|
||||||
|
"DELETE",
|
||||||
|
JSON.stringify({
|
||||||
|
deletedAt,
|
||||||
|
version: nextTask.version,
|
||||||
|
updatedAt: nextTask.updatedAt
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
await localDb.transaction("rw", localDb.tasks, localDb.opLogs, async () => {
|
await localDb.transaction("rw", localDb.tasks, localDb.opLogs, async () => {
|
||||||
await localDb.tasks.put(nextTask);
|
await localDb.tasks.put(nextTask);
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ function resolveApiBaseUrl(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function parseErrorMessage(response: Response): Promise<string> {
|
async function parseErrorMessage(response: Response): Promise<string> {
|
||||||
|
if (response.status === 413) {
|
||||||
|
return "单次同步内容过大,请精简本次任务内容或等待系统分批重试。";
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = (await response.json()) as { message?: string | string[] };
|
const body = (await response.json()) as { message?: string | string[] };
|
||||||
if (Array.isArray(body.message)) {
|
if (Array.isArray(body.message)) {
|
||||||
@@ -57,10 +61,56 @@ async function parseErrorMessage(response: Response): Promise<string> {
|
|||||||
return `请求失败(${response.status})`;
|
return `请求失败(${response.status})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SyncPushOperationRequest = {
|
||||||
|
opId: string;
|
||||||
|
entityId: string;
|
||||||
|
entityType: LocalOpLogRecord["entityType"];
|
||||||
|
action: LocalOpLogRecord["action"];
|
||||||
|
payload: string;
|
||||||
|
clientTs: number;
|
||||||
|
deviceId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function compactOperationPayload(payload: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(payload) as unknown;
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextPayload = { ...(parsed as Record<string, unknown>) };
|
||||||
|
if (nextPayload.contentJson !== undefined && nextPayload.contentJson !== null) {
|
||||||
|
delete nextPayload.contentText;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(nextPayload);
|
||||||
|
} catch {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeSyncOperationForRequest(
|
||||||
|
operation: LocalOpLogRecord
|
||||||
|
): SyncPushOperationRequest {
|
||||||
|
return {
|
||||||
|
opId: operation.opId,
|
||||||
|
entityId: operation.entityId,
|
||||||
|
entityType: operation.entityType,
|
||||||
|
action: operation.action,
|
||||||
|
payload: compactOperationPayload(operation.payload),
|
||||||
|
clientTs: operation.clientTs,
|
||||||
|
deviceId: operation.deviceId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function pushSyncOperations(
|
export async function pushSyncOperations(
|
||||||
userId: string,
|
userId: string,
|
||||||
operations: LocalOpLogRecord[]
|
operations: LocalOpLogRecord[]
|
||||||
): Promise<SyncPushResult> {
|
): Promise<SyncPushResult> {
|
||||||
|
const requestOperations = operations.map((operation) =>
|
||||||
|
serializeSyncOperationForRequest(operation)
|
||||||
|
);
|
||||||
|
|
||||||
const response = await fetch(`${resolveApiBaseUrl()}/sync/push`, {
|
const response = await fetch(`${resolveApiBaseUrl()}/sync/push`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -68,15 +118,7 @@ export async function pushSyncOperations(
|
|||||||
"x-user-id": userId
|
"x-user-id": userId
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
operations: operations.map((operation) => ({
|
operations: requestOperations
|
||||||
opId: operation.opId,
|
|
||||||
entityId: operation.entityId,
|
|
||||||
entityType: operation.entityType,
|
|
||||||
action: operation.action,
|
|
||||||
payload: operation.payload,
|
|
||||||
clientTs: operation.clientTs,
|
|
||||||
deviceId: operation.deviceId
|
|
||||||
}))
|
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import {
|
||||||
|
localDb,
|
||||||
|
type LocalSyncInboxRecord,
|
||||||
|
type LocalTaskPriority,
|
||||||
|
type LocalTaskRecord,
|
||||||
|
type LocalTaskStatus
|
||||||
|
} from "@/services/local-db";
|
||||||
|
import { listPendingRemoteOperations } from "@/services/local-sync-repo";
|
||||||
|
|
||||||
|
const TASK_PRIORITY_VALUES: LocalTaskPriority[] = ["LOW", "MEDIUM", "HIGH", "URGENT"];
|
||||||
|
const TASK_STATUS_VALUES: LocalTaskStatus[] = ["TODO", "IN_PROGRESS", "DONE", "ARCHIVED"];
|
||||||
|
|
||||||
|
type RemoteTaskPayload = {
|
||||||
|
userId?: unknown;
|
||||||
|
title?: unknown;
|
||||||
|
contentJson?: unknown;
|
||||||
|
contentText?: unknown;
|
||||||
|
priority?: unknown;
|
||||||
|
status?: unknown;
|
||||||
|
ddlAt?: unknown;
|
||||||
|
version?: unknown;
|
||||||
|
createdAt?: unknown;
|
||||||
|
updatedAt?: unknown;
|
||||||
|
deletedAt?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizePriority(value: unknown, fallback: LocalTaskPriority): LocalTaskPriority {
|
||||||
|
if (typeof value === "string" && TASK_PRIORITY_VALUES.includes(value as LocalTaskPriority)) {
|
||||||
|
return value as LocalTaskPriority;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStatus(value: unknown, fallback: LocalTaskStatus): LocalTaskStatus {
|
||||||
|
if (typeof value === "string" && TASK_STATUS_VALUES.includes(value as LocalTaskStatus)) {
|
||||||
|
return value as LocalTaskStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStringOrNull(value: unknown, fallback: string | null): string | null {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectTextFromRichContent(value: unknown, fragments: string[]): void {
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = value as {
|
||||||
|
text?: unknown;
|
||||||
|
content?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof node.text === "string" && node.text.trim().length > 0) {
|
||||||
|
fragments.push(node.text.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(node.content)) {
|
||||||
|
for (const child of node.content) {
|
||||||
|
collectTextFromRichContent(child, fragments);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTextFromContentJson(contentJson: string | null): string | null {
|
||||||
|
if (!contentJson) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(contentJson) as unknown;
|
||||||
|
const fragments: string[] = [];
|
||||||
|
collectTextFromRichContent(parsed, fragments);
|
||||||
|
return fragments.length > 0 ? fragments.join(" ") : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNullableNumber(value: unknown, fallback: number | null): number | null {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePositiveNumber(value: unknown, fallback: number): number {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOperationPayload(operation: LocalSyncInboxRecord): RemoteTaskPayload {
|
||||||
|
if (!operation.payload) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(operation.payload) as unknown;
|
||||||
|
if (!parsed || typeof parsed !== "object") {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed as RemoteTaskPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFallbackTask(
|
||||||
|
operation: LocalSyncInboxRecord,
|
||||||
|
userId: string,
|
||||||
|
updatedAt: number,
|
||||||
|
version: number
|
||||||
|
): LocalTaskRecord {
|
||||||
|
return {
|
||||||
|
id: operation.entityId,
|
||||||
|
userId,
|
||||||
|
title: "未命名任务",
|
||||||
|
contentJson: null,
|
||||||
|
contentText: null,
|
||||||
|
priority: "MEDIUM",
|
||||||
|
status: "TODO",
|
||||||
|
ddlAt: null,
|
||||||
|
version,
|
||||||
|
createdAt: updatedAt,
|
||||||
|
updatedAt,
|
||||||
|
deletedAt: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIncomingTaskRecord(
|
||||||
|
operation: LocalSyncInboxRecord,
|
||||||
|
currentTask: LocalTaskRecord | undefined
|
||||||
|
): LocalTaskRecord {
|
||||||
|
const payload = parseOperationPayload(operation);
|
||||||
|
const fallbackVersion = currentTask?.version ?? 1;
|
||||||
|
const version = normalizePositiveNumber(payload.version, fallbackVersion);
|
||||||
|
const updatedAt = normalizePositiveNumber(
|
||||||
|
payload.updatedAt,
|
||||||
|
normalizePositiveNumber(payload.deletedAt, operation.clientTs)
|
||||||
|
);
|
||||||
|
const fallbackTask =
|
||||||
|
currentTask ?? createFallbackTask(operation, operation.userId, updatedAt, version);
|
||||||
|
const contentJson = normalizeStringOrNull(payload.contentJson, fallbackTask.contentJson);
|
||||||
|
const contentText = normalizeStringOrNull(
|
||||||
|
payload.contentText,
|
||||||
|
extractTextFromContentJson(contentJson) ?? fallbackTask.contentText
|
||||||
|
);
|
||||||
|
|
||||||
|
if (operation.action === "DELETE") {
|
||||||
|
const deletedAt = normalizePositiveNumber(payload.deletedAt, updatedAt);
|
||||||
|
return {
|
||||||
|
...fallbackTask,
|
||||||
|
version,
|
||||||
|
updatedAt: deletedAt,
|
||||||
|
deletedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...fallbackTask,
|
||||||
|
userId: typeof payload.userId === "string" ? payload.userId : fallbackTask.userId,
|
||||||
|
title:
|
||||||
|
typeof payload.title === "string" && payload.title.trim().length > 0
|
||||||
|
? payload.title
|
||||||
|
: fallbackTask.title,
|
||||||
|
contentJson,
|
||||||
|
contentText,
|
||||||
|
priority: normalizePriority(payload.priority, fallbackTask.priority),
|
||||||
|
status: normalizeStatus(payload.status, fallbackTask.status),
|
||||||
|
ddlAt: normalizeNullableNumber(payload.ddlAt, fallbackTask.ddlAt),
|
||||||
|
version,
|
||||||
|
createdAt: normalizePositiveNumber(payload.createdAt, fallbackTask.createdAt),
|
||||||
|
updatedAt,
|
||||||
|
deletedAt: normalizeNullableNumber(payload.deletedAt, null)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOperationTieBreaker(operation: LocalSyncInboxRecord): number {
|
||||||
|
if (operation.action === "DELETE") {
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operation.action === "UPDATE") {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldApplyIncomingTask(
|
||||||
|
currentTask: LocalTaskRecord | undefined,
|
||||||
|
incomingTask: LocalTaskRecord,
|
||||||
|
operation: LocalSyncInboxRecord
|
||||||
|
): boolean {
|
||||||
|
if (!currentTask) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (incomingTask.updatedAt > currentTask.updatedAt) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (incomingTask.updatedAt < currentTask.updatedAt) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (incomingTask.version > currentTask.version) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (incomingTask.version < currentTask.version) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getOperationTieBreaker(operation) >= (currentTask.deletedAt === null ? 1 : 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyPendingRemoteOperations(userId: string): Promise<number> {
|
||||||
|
const pendingOperations = await listPendingRemoteOperations(userId);
|
||||||
|
if (pendingOperations.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const appliedAt = Date.now();
|
||||||
|
|
||||||
|
await localDb.transaction("rw", localDb.tasks, localDb.syncInbox, async () => {
|
||||||
|
for (const operation of pendingOperations) {
|
||||||
|
if (operation.entityType !== "TASK") {
|
||||||
|
await localDb.syncInbox.update(operation.opId, { appliedAt });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTask = await localDb.tasks.get(operation.entityId);
|
||||||
|
const incomingTask = buildIncomingTaskRecord(operation, currentTask);
|
||||||
|
|
||||||
|
if (shouldApplyIncomingTask(currentTask, incomingTask, operation)) {
|
||||||
|
await localDb.tasks.put(incomingTask);
|
||||||
|
}
|
||||||
|
|
||||||
|
await localDb.syncInbox.update(operation.opId, { appliedAt });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return pendingOperations.length;
|
||||||
|
}
|
||||||
@@ -6,15 +6,45 @@ import {
|
|||||||
markSyncOperationsSucceeded,
|
markSyncOperationsSucceeded,
|
||||||
saveLocalSyncState
|
saveLocalSyncState
|
||||||
} from "@/services/local-sync-repo";
|
} from "@/services/local-sync-repo";
|
||||||
import { pullSyncOperations, pushSyncOperations } from "@/services/sync-api";
|
import { applyPendingRemoteOperations } from "@/services/sync-merge";
|
||||||
|
import {
|
||||||
|
pullSyncOperations,
|
||||||
|
pushSyncOperations,
|
||||||
|
serializeSyncOperationForRequest
|
||||||
|
} from "@/services/sync-api";
|
||||||
|
import type { LocalOpLogRecord } from "@/services/local-db";
|
||||||
|
|
||||||
const PUSH_BATCH_LIMIT = 20;
|
const PUSH_BATCH_LIMIT = 20;
|
||||||
|
const PUSH_BATCH_MAX_BYTES = 256 * 1024;
|
||||||
const PULL_BATCH_LIMIT = 100;
|
const PULL_BATCH_LIMIT = 100;
|
||||||
const MAX_PULL_PAGES_PER_CYCLE = 5;
|
const MAX_PULL_PAGES_PER_CYCLE = 5;
|
||||||
|
|
||||||
|
function estimateOperationBytes(operation: LocalOpLogRecord): number {
|
||||||
|
return new TextEncoder().encode(JSON.stringify(serializeSyncOperationForRequest(operation)))
|
||||||
|
.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPushBatch(operations: LocalOpLogRecord[]): LocalOpLogRecord[] {
|
||||||
|
const batch: LocalOpLogRecord[] = [];
|
||||||
|
let batchBytes = 0;
|
||||||
|
|
||||||
|
for (const operation of operations) {
|
||||||
|
const operationBytes = estimateOperationBytes(operation);
|
||||||
|
if (batch.length > 0 && batchBytes + operationBytes > PUSH_BATCH_MAX_BYTES) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
batch.push(operation);
|
||||||
|
batchBytes += operationBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
return batch;
|
||||||
|
}
|
||||||
|
|
||||||
export type SyncCycleResult = {
|
export type SyncCycleResult = {
|
||||||
pushedCount: number;
|
pushedCount: number;
|
||||||
pulledCount: number;
|
pulledCount: number;
|
||||||
|
appliedRemoteCount: number;
|
||||||
lastSyncedAt: number;
|
lastSyncedAt: number;
|
||||||
hasFailures: boolean;
|
hasFailures: boolean;
|
||||||
failureMessage: string | null;
|
failureMessage: string | null;
|
||||||
@@ -24,15 +54,17 @@ export async function runSyncWorkerCycle(userId: string): Promise<SyncCycleResul
|
|||||||
const lastSyncedAt = Date.now();
|
const lastSyncedAt = Date.now();
|
||||||
let pushedCount = 0;
|
let pushedCount = 0;
|
||||||
let pulledCount = 0;
|
let pulledCount = 0;
|
||||||
|
let appliedRemoteCount = 0;
|
||||||
let hasFailures = false;
|
let hasFailures = false;
|
||||||
let failureMessage: string | null = null;
|
let failureMessage: string | null = null;
|
||||||
|
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const pendingOperations = await listPendingSyncOperations(PUSH_BATCH_LIMIT);
|
const pendingCandidates = await listPendingSyncOperations(PUSH_BATCH_LIMIT);
|
||||||
if (pendingOperations.length === 0) {
|
if (pendingCandidates.length === 0) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pendingOperations = createPushBatch(pendingCandidates);
|
||||||
const pushResult = await pushSyncOperations(userId, pendingOperations);
|
const pushResult = await pushSyncOperations(userId, pendingOperations);
|
||||||
const syncedOperationIds = pushResult.results
|
const syncedOperationIds = pushResult.results
|
||||||
.filter((result) => result.status === "accepted" || result.status === "duplicate")
|
.filter((result) => result.status === "accepted" || result.status === "duplicate")
|
||||||
@@ -55,7 +87,10 @@ export async function runSyncWorkerCycle(userId: string): Promise<SyncCycleResul
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pendingOperations.length < PUSH_BATCH_LIMIT) {
|
if (
|
||||||
|
pendingCandidates.length < PUSH_BATCH_LIMIT ||
|
||||||
|
pendingOperations.length < pendingCandidates.length
|
||||||
|
) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,9 +129,12 @@ export async function runSyncWorkerCycle(userId: string): Promise<SyncCycleResul
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
appliedRemoteCount = await applyPendingRemoteOperations(userId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pushedCount,
|
pushedCount,
|
||||||
pulledCount,
|
pulledCount,
|
||||||
|
appliedRemoteCount,
|
||||||
lastSyncedAt,
|
lastSyncedAt,
|
||||||
hasFailures,
|
hasFailures,
|
||||||
failureMessage
|
failureMessage
|
||||||
|
|||||||
Reference in New Issue
Block a user