270 lines
7.1 KiB
TypeScript
270 lines
7.1 KiB
TypeScript
import {
|
|
localDb,
|
|
type LocalSyncInboxRecord,
|
|
type LocalTaskPriority,
|
|
type LocalTaskRecord,
|
|
type LocalTaskStatus
|
|
} from "@/services/local-db";
|
|
import { listPendingRemoteOperations } from "@/services/local-sync-repo";
|
|
import {
|
|
decryptTaskRecord,
|
|
encryptTaskRecord,
|
|
shouldEncryptTaskRecord
|
|
} from "@/services/local-sensitive-codec";
|
|
|
|
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 storedTask = await localDb.tasks.get(operation.entityId);
|
|
const currentTask = storedTask ? await decryptTaskRecord(storedTask) : undefined;
|
|
const incomingTask = buildIncomingTaskRecord(operation, currentTask);
|
|
|
|
if (shouldApplyIncomingTask(currentTask, incomingTask, operation)) {
|
|
await localDb.tasks.put(await encryptTaskRecord(incomingTask));
|
|
} else if (storedTask && currentTask && shouldEncryptTaskRecord(storedTask)) {
|
|
await localDb.tasks.put(await encryptTaskRecord(currentTask));
|
|
}
|
|
|
|
await localDb.syncInbox.update(operation.opId, { appliedAt });
|
|
}
|
|
});
|
|
|
|
return pendingOperations.length;
|
|
}
|