mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
3396 lines
106 KiB
JavaScript
3396 lines
106 KiB
JavaScript
import { BmeDatabase } from "./bme-db.js";
|
|
import {
|
|
MANUAL_BACKUP_BATCH_JOURNAL_COVERAGE_KEY,
|
|
PROCESSED_MESSAGE_HASH_VERSION,
|
|
} from "../runtime/runtime-state.js";
|
|
|
|
const BME_SYNC_FILE_PREFIX = "ST-BME_sync_";
|
|
const BME_SYNC_FILE_SUFFIX = ".json";
|
|
const BME_SYNC_FILENAME_MAX_LENGTH = 180;
|
|
const BME_REMOTE_SYNC_FORMAT_VERSION_V2 = 2;
|
|
const BME_REMOTE_SYNC_NODE_CHUNK_SIZE = 2000;
|
|
const BME_REMOTE_SYNC_EDGE_CHUNK_SIZE = 4000;
|
|
const BME_REMOTE_SYNC_TOMBSTONE_CHUNK_SIZE = 2000;
|
|
const BME_BACKUP_FILE_PREFIX = "ST-BME_backup_";
|
|
const BME_BACKUP_MANIFEST_FILENAME = "ST-BME_BackupManifest.json";
|
|
const BME_BACKUP_SCHEMA_VERSION = 1;
|
|
|
|
export const BME_SYNC_DEVICE_ID_KEY = "st_bme_sync_device_id_v1";
|
|
export const BME_SYNC_UPLOAD_DEBOUNCE_MS = 2500;
|
|
|
|
const syncInFlightByChatId = new Map();
|
|
const uploadDebounceTimerByChatId = new Map();
|
|
const sanitizedFilenameByChatId = new Map();
|
|
|
|
let visibilitySyncInstalled = false;
|
|
let lastVisibilityState = "visible";
|
|
|
|
const RUNTIME_HISTORY_META_KEY = "runtimeHistoryState";
|
|
const RUNTIME_VECTOR_META_KEY = "runtimeVectorIndexState";
|
|
const RUNTIME_BATCH_JOURNAL_META_KEY = "runtimeBatchJournal";
|
|
const RUNTIME_LAST_RECALL_META_KEY = "runtimeLastRecallResult";
|
|
const RUNTIME_SUMMARY_STATE_META_KEY = "runtimeSummaryState";
|
|
const RUNTIME_MAINTENANCE_JOURNAL_META_KEY = "maintenanceJournal";
|
|
const RUNTIME_KNOWLEDGE_STATE_META_KEY = "knowledgeState";
|
|
const RUNTIME_REGION_STATE_META_KEY = "regionState";
|
|
const RUNTIME_TIMELINE_STATE_META_KEY = "timelineState";
|
|
const RUNTIME_LAST_PROCESSED_SEQ_META_KEY = "runtimeLastProcessedSeq";
|
|
const RUNTIME_GRAPH_VERSION_META_KEY = "runtimeGraphVersion";
|
|
const RUNTIME_BATCH_JOURNAL_LIMIT = 96;
|
|
const MANUAL_BACKUP_BATCH_JOURNAL_LIMIT = 4;
|
|
|
|
function normalizeChatId(chatId) {
|
|
return String(chatId ?? "").trim();
|
|
}
|
|
|
|
export function buildRestoreSafetyChatId(chatId) {
|
|
return `__restore_safety__${normalizeChatId(chatId)}`;
|
|
}
|
|
|
|
function readSyncTimingNow() {
|
|
if (typeof performance === "object" && typeof performance.now === "function") {
|
|
return performance.now();
|
|
}
|
|
return Date.now();
|
|
}
|
|
|
|
function normalizeSyncTimingMs(value = 0) {
|
|
return Math.round((Number(value) || 0) * 10) / 10;
|
|
}
|
|
|
|
function finalizeSyncTimings(record = {}, startedAt = 0) {
|
|
const result = {};
|
|
for (const [key, value] of Object.entries(record || {})) {
|
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
result[key] = normalizeSyncTimingMs(value);
|
|
}
|
|
}
|
|
if (startedAt > 0) {
|
|
result.totalMs = normalizeSyncTimingMs(readSyncTimingNow() - startedAt);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function resolveCloudStorageMode(options = {}) {
|
|
const mode =
|
|
typeof options.getCloudStorageMode === "function"
|
|
? options.getCloudStorageMode()
|
|
: options.cloudStorageMode;
|
|
return String(mode || "automatic").trim().toLowerCase() === "manual"
|
|
? "manual"
|
|
: "automatic";
|
|
}
|
|
|
|
function isAutomaticCloudMode(options = {}) {
|
|
return resolveCloudStorageMode(options) === "automatic";
|
|
}
|
|
|
|
function createStableFilenameHash(input = "") {
|
|
let hash = 2166136261;
|
|
const normalized = String(input ?? "");
|
|
for (let index = 0; index < normalized.length; index++) {
|
|
hash ^= normalized.charCodeAt(index);
|
|
hash = Math.imul(hash, 16777619);
|
|
}
|
|
return (hash >>> 0).toString(36);
|
|
}
|
|
|
|
function normalizeRemoteFilenameCandidate(fileName, fallbackValue = "ST-BME_sync_unknown.json") {
|
|
const raw = String(fileName ?? "");
|
|
const normalized = typeof raw.normalize === "function" ? raw.normalize("NFKD") : raw;
|
|
const sanitized = normalized
|
|
.replace(/[\u0300-\u036f]/g, "")
|
|
.replace(/[^A-Za-z0-9._-]+/g, "_")
|
|
.replace(/_+/g, "_")
|
|
.replace(/^\.+/g, "")
|
|
.slice(0, BME_SYNC_FILENAME_MAX_LENGTH)
|
|
.trim();
|
|
return sanitized || fallbackValue;
|
|
}
|
|
|
|
function buildBackupFilename(chatId) {
|
|
const normalizedChatId = normalizeChatId(chatId);
|
|
const hash = createStableFilenameHash(normalizedChatId || "unknown");
|
|
const rawSlug = normalizeRemoteFilenameCandidate(normalizedChatId, "");
|
|
const suffixPart = `-${hash}${BME_SYNC_FILE_SUFFIX}`;
|
|
const maxSlugLength = Math.max(
|
|
0,
|
|
BME_SYNC_FILENAME_MAX_LENGTH -
|
|
BME_BACKUP_FILE_PREFIX.length -
|
|
suffixPart.length,
|
|
);
|
|
const safeSlug = rawSlug
|
|
.slice(0, maxSlugLength)
|
|
.replace(/^[_.-]+|[_.-]+$/g, "");
|
|
const core = safeSlug
|
|
? `${BME_BACKUP_FILE_PREFIX}${safeSlug}-${hash}`
|
|
: `${BME_BACKUP_FILE_PREFIX}${hash}`;
|
|
return `${core}${BME_SYNC_FILE_SUFFIX}`;
|
|
}
|
|
|
|
function normalizeLegacyRemoteFilenameCandidate(
|
|
fileName,
|
|
fallbackValue = "ST-BME_sync_unknown.json",
|
|
) {
|
|
const raw = String(fileName ?? "");
|
|
const normalized =
|
|
typeof raw.normalize === "function" ? raw.normalize("NFKD") : raw;
|
|
const sanitized = normalized
|
|
.replace(/[\u0300-\u036f]/g, "")
|
|
.replace(/[^A-Za-z0-9._~-]+/g, "_")
|
|
.replace(/_+/g, "_")
|
|
.replace(/^\.+/g, "")
|
|
.slice(0, BME_SYNC_FILENAME_MAX_LENGTH)
|
|
.trim();
|
|
return sanitized || fallbackValue;
|
|
}
|
|
|
|
function buildSyncFilename(chatId) {
|
|
const normalizedChatId = normalizeChatId(chatId);
|
|
const legacyName = `${BME_SYNC_FILE_PREFIX}${normalizedChatId}${BME_SYNC_FILE_SUFFIX}`;
|
|
if (
|
|
normalizedChatId
|
|
&& /^[A-Za-z0-9._-]+$/.test(normalizedChatId)
|
|
&& legacyName.length <= BME_SYNC_FILENAME_MAX_LENGTH
|
|
) {
|
|
return legacyName;
|
|
}
|
|
|
|
const hash = createStableFilenameHash(normalizedChatId || "unknown");
|
|
const rawSlug = normalizeRemoteFilenameCandidate(normalizedChatId, "");
|
|
const suffixPart = `-${hash}${BME_SYNC_FILE_SUFFIX}`;
|
|
const maxSlugLength = Math.max(
|
|
0,
|
|
BME_SYNC_FILENAME_MAX_LENGTH - BME_SYNC_FILE_PREFIX.length - suffixPart.length,
|
|
);
|
|
const safeSlug = rawSlug.slice(0, maxSlugLength).replace(/^[_.-]+|[_.-]+$/g, "");
|
|
const core = safeSlug
|
|
? `${BME_SYNC_FILE_PREFIX}${safeSlug}-${hash}`
|
|
: `${BME_SYNC_FILE_PREFIX}${hash}`;
|
|
return `${core}${BME_SYNC_FILE_SUFFIX}`;
|
|
}
|
|
|
|
function buildLegacyRawSyncFilename(chatId) {
|
|
return `${BME_SYNC_FILE_PREFIX}${normalizeChatId(chatId)}${BME_SYNC_FILE_SUFFIX}`;
|
|
}
|
|
|
|
function rememberResolvedSyncFilename(chatId, filename) {
|
|
const normalizedChatId = normalizeChatId(chatId);
|
|
const normalizedFilename = String(filename || "").trim();
|
|
if (!normalizedChatId || !normalizedFilename) return "";
|
|
sanitizedFilenameByChatId.set(normalizedChatId, normalizedFilename);
|
|
return normalizedFilename;
|
|
}
|
|
|
|
function normalizeRevision(value) {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed) || parsed < 0) return 0;
|
|
return Math.floor(parsed);
|
|
}
|
|
|
|
function normalizeTimestamp(value, fallback = Date.now()) {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed)) return Math.floor(Number(fallback) || Date.now());
|
|
return Math.floor(parsed);
|
|
}
|
|
|
|
function sanitizeSnapshotRecordArray(records) {
|
|
return Array.isArray(records)
|
|
? records
|
|
.filter((item) => item && typeof item === "object" && !Array.isArray(item))
|
|
.map((item) => ({ ...item }))
|
|
: [];
|
|
}
|
|
|
|
function toSerializableData(value, fallback = null) {
|
|
if (value == null) return fallback;
|
|
|
|
if (typeof globalThis.structuredClone === "function") {
|
|
try {
|
|
return globalThis.structuredClone(value);
|
|
} catch {
|
|
// no-op
|
|
}
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(JSON.stringify(value));
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function normalizeBackupManifestEntry(rawEntry = {}) {
|
|
if (!rawEntry || typeof rawEntry !== "object" || Array.isArray(rawEntry)) {
|
|
return null;
|
|
}
|
|
|
|
const filename = String(rawEntry.filename || "").trim();
|
|
if (
|
|
!filename
|
|
|| !filename.startsWith(BME_BACKUP_FILE_PREFIX)
|
|
|| !filename.endsWith(BME_SYNC_FILE_SUFFIX)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
filename,
|
|
serverPath: String(rawEntry.serverPath || "").trim(),
|
|
chatId: normalizeChatId(rawEntry.chatId),
|
|
revision: normalizeRevision(rawEntry.revision),
|
|
lastModified: normalizeTimestamp(rawEntry.lastModified, 0),
|
|
backupTime: normalizeTimestamp(rawEntry.backupTime, 0),
|
|
size: normalizeNonNegativeInteger(rawEntry.size, 0),
|
|
schemaVersion: normalizeNonNegativeInteger(rawEntry.schemaVersion, 0),
|
|
};
|
|
}
|
|
|
|
function normalizeBackupEnvelope(payload = {}, chatId = "") {
|
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
return null;
|
|
}
|
|
|
|
const normalizedSnapshot = normalizeSyncSnapshot(payload.snapshot, chatId);
|
|
return {
|
|
kind: String(payload.kind || "st-bme-backup").trim().toLowerCase(),
|
|
version: normalizeNonNegativeInteger(payload.version, 0),
|
|
chatId: normalizeChatId(payload.chatId || normalizedSnapshot.meta?.chatId),
|
|
createdAt: normalizeTimestamp(payload.createdAt, 0),
|
|
sourceDeviceId: String(payload.sourceDeviceId || "").trim(),
|
|
snapshot: normalizedSnapshot,
|
|
};
|
|
}
|
|
|
|
function getStorage() {
|
|
const storage = globalThis.localStorage;
|
|
if (!storage || typeof storage.getItem !== "function" || typeof storage.setItem !== "function") {
|
|
return null;
|
|
}
|
|
return storage;
|
|
}
|
|
|
|
function getRandomBytes(size = 16) {
|
|
if (globalThis.crypto?.getRandomValues) {
|
|
const buffer = new Uint8Array(size);
|
|
globalThis.crypto.getRandomValues(buffer);
|
|
return buffer;
|
|
}
|
|
|
|
const fallback = new Uint8Array(size);
|
|
for (let index = 0; index < size; index++) {
|
|
fallback[index] = Math.floor(Math.random() * 256);
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
function createFallbackDeviceId() {
|
|
const bytes = getRandomBytes(16);
|
|
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
}
|
|
|
|
function encodeBase64Utf8(text) {
|
|
const normalizedText = String(text ?? "");
|
|
|
|
if (typeof globalThis.btoa === "function" && typeof globalThis.TextEncoder === "function") {
|
|
const bytes = new TextEncoder().encode(normalizedText);
|
|
const chunkSize = 0x8000;
|
|
let binary = "";
|
|
for (let index = 0; index < bytes.length; index += chunkSize) {
|
|
binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize));
|
|
}
|
|
return globalThis.btoa(binary);
|
|
}
|
|
|
|
if (typeof Buffer !== "undefined") {
|
|
return Buffer.from(normalizedText, "utf8").toString("base64");
|
|
}
|
|
|
|
throw new Error("当前环境缺少 base64 编码能力");
|
|
}
|
|
|
|
function decodeBase64Utf8(base64Text) {
|
|
const normalizedBase64 = String(base64Text ?? "");
|
|
|
|
if (typeof globalThis.atob === "function" && typeof globalThis.TextDecoder === "function") {
|
|
const binary = globalThis.atob(normalizedBase64);
|
|
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
return new TextDecoder().decode(bytes);
|
|
}
|
|
|
|
if (typeof Buffer !== "undefined") {
|
|
return Buffer.from(normalizedBase64, "base64").toString("utf8");
|
|
}
|
|
|
|
throw new Error("当前环境缺少 base64 解码能力");
|
|
}
|
|
|
|
function getFetch(options = {}) {
|
|
const fetchImpl = options.fetch || globalThis.fetch;
|
|
if (typeof fetchImpl !== "function") {
|
|
throw new Error("fetch 不可用,无法执行 ST-BME 同步请求");
|
|
}
|
|
return fetchImpl;
|
|
}
|
|
|
|
async function getSafetyDb(chatId, options = {}) {
|
|
if (typeof options.getSafetyDb === "function") {
|
|
return await options.getSafetyDb(chatId);
|
|
}
|
|
|
|
const db = new BmeDatabase(buildRestoreSafetyChatId(chatId));
|
|
await db.open();
|
|
return db;
|
|
}
|
|
|
|
async function fetchBackupManifest(options = {}) {
|
|
const fetchImpl = getFetch(options);
|
|
const response = await fetchImpl(
|
|
`/user/files/${BME_BACKUP_MANIFEST_FILENAME}?t=${Date.now()}`,
|
|
{
|
|
method: "GET",
|
|
cache: "no-store",
|
|
},
|
|
);
|
|
if (response.status === 404) {
|
|
return [];
|
|
}
|
|
if (!response.ok) {
|
|
const errorText = await response.text().catch(() => response.statusText);
|
|
throw new Error(errorText || `manifest read failed: HTTP ${response.status}`);
|
|
}
|
|
const rawPayload = await response.json();
|
|
if (!Array.isArray(rawPayload)) {
|
|
throw new Error("backup manifest payload is not an array");
|
|
}
|
|
return rawPayload.map(normalizeBackupManifestEntry).filter(Boolean);
|
|
}
|
|
|
|
async function writeBackupManifest(entries = [], options = {}) {
|
|
const fetchImpl = getFetch(options);
|
|
const payload = JSON.stringify(entries);
|
|
const response = await fetchImpl("/api/files/upload", {
|
|
method: "POST",
|
|
headers: {
|
|
...getRequestHeadersSafe(options),
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
name: BME_BACKUP_MANIFEST_FILENAME,
|
|
data: encodeBase64Utf8(payload),
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text().catch(() => response.statusText);
|
|
throw new Error(errorText || `HTTP ${response.status}`);
|
|
}
|
|
}
|
|
|
|
async function upsertBackupManifestEntry(entry, options = {}) {
|
|
const existingEntries = await fetchBackupManifest(options);
|
|
const filteredEntries = existingEntries.filter(
|
|
(candidate) => candidate.filename !== entry.filename,
|
|
);
|
|
filteredEntries.push(normalizeBackupManifestEntry(entry));
|
|
filteredEntries.sort((left, right) => right.backupTime - left.backupTime);
|
|
await writeBackupManifest(filteredEntries, options);
|
|
}
|
|
|
|
function normalizeSelectedBackupFilename(filename) {
|
|
const normalized = String(filename ?? "")
|
|
.trim()
|
|
.replace(/^\/+/, "");
|
|
if (
|
|
!normalized
|
|
|| normalized === BME_BACKUP_MANIFEST_FILENAME
|
|
|| /[\\/]/.test(normalized)
|
|
) {
|
|
return "";
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function normalizeSelectedBackupServerPath(serverPath, fallbackFilename = "") {
|
|
const normalizedPath = String(serverPath ?? "")
|
|
.trim()
|
|
.replace(/\\/g, "/")
|
|
.replace(/^\/+/, "");
|
|
if (normalizedPath && !normalizedPath.includes("..")) {
|
|
return `/${normalizedPath}`;
|
|
}
|
|
|
|
const normalizedFilename = normalizeSelectedBackupFilename(fallbackFilename);
|
|
return normalizedFilename ? `/user/files/${normalizedFilename}` : "";
|
|
}
|
|
|
|
function sortBackupManifestEntries(entries = []) {
|
|
return [...entries].sort((left, right) => {
|
|
const timeDelta =
|
|
normalizeTimestamp(right.backupTime, 0) -
|
|
normalizeTimestamp(left.backupTime, 0);
|
|
if (timeDelta !== 0) return timeDelta;
|
|
|
|
const modifiedDelta =
|
|
normalizeTimestamp(right.lastModified, 0) -
|
|
normalizeTimestamp(left.lastModified, 0);
|
|
if (modifiedDelta !== 0) return modifiedDelta;
|
|
|
|
return String(left.filename || "").localeCompare(
|
|
String(right.filename || ""),
|
|
);
|
|
});
|
|
}
|
|
|
|
async function resolveBackupLookupContext(chatId, options = {}) {
|
|
const normalizedChatId = normalizeChatId(chatId);
|
|
const explicitFilename = normalizeSelectedBackupFilename(
|
|
options.filename || options.backupFilename,
|
|
);
|
|
const explicitServerPath = normalizeSelectedBackupServerPath(
|
|
options.serverPath,
|
|
explicitFilename,
|
|
);
|
|
|
|
let manifestEntries = [];
|
|
let manifestError = null;
|
|
try {
|
|
manifestEntries = sortBackupManifestEntries(await fetchBackupManifest(options));
|
|
} catch (error) {
|
|
manifestError = error;
|
|
}
|
|
|
|
const candidates = [];
|
|
const candidateIndexByFilename = new Map();
|
|
const pushCandidate = (filename, serverPath = "") => {
|
|
const normalizedFilename = normalizeSelectedBackupFilename(filename);
|
|
if (!normalizedFilename) return;
|
|
|
|
const normalizedServerPath = normalizeSelectedBackupServerPath(
|
|
serverPath,
|
|
normalizedFilename,
|
|
);
|
|
const existingIndex = candidateIndexByFilename.get(normalizedFilename);
|
|
if (existingIndex != null) {
|
|
if (
|
|
normalizedServerPath &&
|
|
!candidates[existingIndex].serverPath
|
|
) {
|
|
candidates[existingIndex].serverPath = normalizedServerPath;
|
|
}
|
|
return;
|
|
}
|
|
|
|
candidateIndexByFilename.set(normalizedFilename, candidates.length);
|
|
candidates.push({
|
|
filename: normalizedFilename,
|
|
serverPath: normalizedServerPath,
|
|
});
|
|
};
|
|
|
|
pushCandidate(explicitFilename, explicitServerPath);
|
|
|
|
if (explicitFilename) {
|
|
for (const entry of manifestEntries) {
|
|
if (entry.filename === explicitFilename) {
|
|
pushCandidate(entry.filename, entry.serverPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const entry of manifestEntries) {
|
|
if (normalizeChatId(entry.chatId) === normalizedChatId) {
|
|
pushCandidate(entry.filename, entry.serverPath);
|
|
}
|
|
}
|
|
|
|
pushCandidate(buildBackupFilename(normalizedChatId));
|
|
|
|
return {
|
|
explicitFilename,
|
|
manifestEntries,
|
|
manifestError,
|
|
candidates,
|
|
};
|
|
}
|
|
|
|
async function readBackupEnvelope(chatId, options = {}) {
|
|
const readStartedAt = readSyncTimingNow();
|
|
const normalizedChatId = normalizeChatId(chatId);
|
|
const lookupStartedAt = readSyncTimingNow();
|
|
const lookup = await resolveBackupLookupContext(normalizedChatId, options);
|
|
const lookupMs = readSyncTimingNow() - lookupStartedAt;
|
|
const fetchImpl = getFetch(options);
|
|
const fallbackFilename = buildBackupFilename(normalizedChatId);
|
|
let lastMissingFilename = lookup.candidates[0]?.filename || fallbackFilename;
|
|
let networkMs = 0;
|
|
let parseMs = 0;
|
|
|
|
for (const candidate of lookup.candidates) {
|
|
try {
|
|
const networkStartedAt = readSyncTimingNow();
|
|
const response = await fetchImpl(
|
|
`${candidate.serverPath || `/user/files/${encodeURIComponent(candidate.filename)}`}?t=${Date.now()}`,
|
|
{
|
|
method: "GET",
|
|
cache: "no-store",
|
|
},
|
|
);
|
|
networkMs += readSyncTimingNow() - networkStartedAt;
|
|
if (response.status === 404) {
|
|
lastMissingFilename = candidate.filename;
|
|
continue;
|
|
}
|
|
if (!response.ok) {
|
|
const errorText = await response.text().catch(() => response.statusText);
|
|
return {
|
|
exists: false,
|
|
filename: candidate.filename,
|
|
envelope: null,
|
|
reason: "backup-read-error",
|
|
error: new Error(errorText || `HTTP ${response.status}`),
|
|
timings: finalizeSyncTimings({ lookupMs, networkMs, parseMs }, readStartedAt),
|
|
};
|
|
}
|
|
|
|
const parseStartedAt = readSyncTimingNow();
|
|
const payload = await response.json();
|
|
parseMs += readSyncTimingNow() - parseStartedAt;
|
|
const envelope = normalizeBackupEnvelope(payload, normalizedChatId);
|
|
if (!envelope) {
|
|
return {
|
|
exists: false,
|
|
filename: candidate.filename,
|
|
envelope: null,
|
|
reason: "invalid-backup",
|
|
timings: finalizeSyncTimings({ lookupMs, networkMs, parseMs }, readStartedAt),
|
|
};
|
|
}
|
|
return {
|
|
exists: true,
|
|
filename: candidate.filename,
|
|
envelope,
|
|
reason: "ok",
|
|
timings: finalizeSyncTimings({ lookupMs, networkMs, parseMs }, readStartedAt),
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
exists: false,
|
|
filename: candidate.filename,
|
|
envelope: null,
|
|
reason: "backup-read-error",
|
|
error,
|
|
timings: finalizeSyncTimings({ lookupMs, networkMs, parseMs }, readStartedAt),
|
|
};
|
|
}
|
|
}
|
|
|
|
return {
|
|
exists: false,
|
|
filename: lastMissingFilename,
|
|
envelope: null,
|
|
reason: "not-found",
|
|
manifestError: lookup.manifestError,
|
|
timings: finalizeSyncTimings({ lookupMs, networkMs, parseMs }, readStartedAt),
|
|
};
|
|
}
|
|
|
|
async function syncDeletedBackupMeta(chatId, remainingEntry, options = {}) {
|
|
try {
|
|
const db = await getDb(chatId, options);
|
|
await patchDbMeta(db, {
|
|
lastBackupUploadedAt: remainingEntry
|
|
? normalizeTimestamp(
|
|
remainingEntry.backupTime || remainingEntry.lastModified,
|
|
0,
|
|
)
|
|
: 0,
|
|
lastBackupFilename: remainingEntry
|
|
? String(remainingEntry.filename || "")
|
|
: "",
|
|
});
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function writeBackupEnvelope(envelope, chatId, options = {}) {
|
|
const writeStartedAt = readSyncTimingNow();
|
|
const normalizedChatId = normalizeChatId(chatId);
|
|
const filename = buildBackupFilename(normalizedChatId);
|
|
const fetchImpl = getFetch(options);
|
|
const serializeStartedAt = readSyncTimingNow();
|
|
const payload = JSON.stringify(envelope);
|
|
const serializeMs = readSyncTimingNow() - serializeStartedAt;
|
|
const uploadStartedAt = readSyncTimingNow();
|
|
const response = await fetchImpl("/api/files/upload", {
|
|
method: "POST",
|
|
headers: {
|
|
...getRequestHeadersSafe(options),
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
name: filename,
|
|
data: encodeBase64Utf8(payload),
|
|
}),
|
|
});
|
|
const uploadMs = readSyncTimingNow() - uploadStartedAt;
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text().catch(() => response.statusText);
|
|
throw new Error(errorText || `HTTP ${response.status}`);
|
|
}
|
|
|
|
const responseParseStartedAt = readSyncTimingNow();
|
|
const uploadResult = await response.json().catch(() => ({}));
|
|
const responseParseMs = readSyncTimingNow() - responseParseStartedAt;
|
|
return {
|
|
filename,
|
|
path: String(uploadResult?.path || `/user/files/${filename}`),
|
|
timings: finalizeSyncTimings(
|
|
{
|
|
serializeMs,
|
|
uploadMs,
|
|
responseParseMs,
|
|
},
|
|
writeStartedAt,
|
|
),
|
|
};
|
|
}
|
|
|
|
export async function createRestoreSafetySnapshot(chatId, snapshot, options = {}) {
|
|
const safetyDb = await getSafetyDb(chatId, options);
|
|
const revision = normalizeRevision(snapshot?.meta?.revision);
|
|
try {
|
|
await safetyDb.importSnapshot(snapshot, {
|
|
mode: "replace",
|
|
preserveRevision: true,
|
|
revision,
|
|
markSyncDirty: false,
|
|
});
|
|
await patchDbMeta(safetyDb, {
|
|
restoreSafetySnapshotExists: true,
|
|
restoreSafetySnapshotCreatedAt: Date.now(),
|
|
restoreSafetySnapshotChatId: normalizeChatId(chatId),
|
|
});
|
|
} finally {
|
|
if (typeof options.getSafetyDb !== "function") {
|
|
await safetyDb.close().catch(() => {});
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function getRestoreSafetySnapshotStatus(chatId, options = {}) {
|
|
const normalizedChatId = normalizeChatId(chatId);
|
|
if (!normalizedChatId) {
|
|
return {
|
|
exists: false,
|
|
chatId: "",
|
|
createdAt: 0,
|
|
reason: "missing-chat-id",
|
|
};
|
|
}
|
|
|
|
try {
|
|
const safetyDb = await getSafetyDb(normalizedChatId, options);
|
|
try {
|
|
const exists = Boolean(
|
|
await readDbMeta(safetyDb, "restoreSafetySnapshotExists", false),
|
|
);
|
|
const createdAt = normalizeTimestamp(
|
|
await readDbMeta(safetyDb, "restoreSafetySnapshotCreatedAt", 0),
|
|
0,
|
|
);
|
|
return {
|
|
exists,
|
|
chatId: normalizedChatId,
|
|
createdAt: exists ? createdAt : 0,
|
|
reason: exists ? "ok" : "not-found",
|
|
};
|
|
} finally {
|
|
if (typeof options.getSafetyDb !== "function") {
|
|
await safetyDb.close().catch(() => {});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
return {
|
|
exists: false,
|
|
chatId: normalizedChatId,
|
|
createdAt: 0,
|
|
reason: "safety-status-error",
|
|
error,
|
|
};
|
|
}
|
|
}
|
|
|
|
export async function rollbackFromRestoreSafetySnapshot(chatId, options = {}) {
|
|
const normalizedChatId = normalizeChatId(chatId);
|
|
if (!normalizedChatId) {
|
|
return {
|
|
restored: false,
|
|
chatId: "",
|
|
reason: "missing-chat-id",
|
|
};
|
|
}
|
|
|
|
try {
|
|
const status = await getRestoreSafetySnapshotStatus(normalizedChatId, options);
|
|
if (!status.exists) {
|
|
return {
|
|
restored: false,
|
|
chatId: normalizedChatId,
|
|
reason: status.reason || "safety-not-found",
|
|
};
|
|
}
|
|
|
|
const safetyDb = await getSafetyDb(normalizedChatId, options);
|
|
try {
|
|
const snapshot = normalizeSyncSnapshot(
|
|
await safetyDb.exportSnapshot(),
|
|
normalizedChatId,
|
|
);
|
|
const db = await getDb(normalizedChatId, options);
|
|
await db.importSnapshot(snapshot, {
|
|
mode: "replace",
|
|
preserveRevision: true,
|
|
revision: normalizeRevision(snapshot.meta?.revision),
|
|
markSyncDirty: false,
|
|
});
|
|
await patchDbMeta(db, {
|
|
deviceId: getOrCreateDeviceId(),
|
|
syncDirty: true,
|
|
syncDirtyReason: "restore-safety-rollback",
|
|
lastBackupRollbackAt: Date.now(),
|
|
});
|
|
await invokeSyncAppliedHook(options, {
|
|
chatId: normalizedChatId,
|
|
action: "restore-backup",
|
|
revision: normalizeRevision(snapshot.meta?.revision),
|
|
});
|
|
return {
|
|
restored: true,
|
|
chatId: normalizedChatId,
|
|
revision: normalizeRevision(snapshot.meta?.revision),
|
|
createdAt: normalizeTimestamp(status.createdAt, 0),
|
|
};
|
|
} finally {
|
|
if (typeof options.getSafetyDb !== "function") {
|
|
await safetyDb.close().catch(() => {});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn("[ST-BME] 回滚本地安全快照失败:", error);
|
|
return {
|
|
restored: false,
|
|
chatId: normalizedChatId,
|
|
reason: "restore-safety-rollback-error",
|
|
error,
|
|
};
|
|
}
|
|
}
|
|
|
|
function getRequestHeadersSafe(options = {}) {
|
|
if (typeof options.getRequestHeaders === "function") {
|
|
try {
|
|
return options.getRequestHeaders() || {};
|
|
} catch (error) {
|
|
console.warn("[ST-BME] 读取请求头失败,回退为空请求头:", error);
|
|
return {};
|
|
}
|
|
}
|
|
return {};
|
|
}
|
|
|
|
function normalizeSyncSnapshot(snapshot = {}, chatId = "") {
|
|
const normalizedChatId = normalizeChatId(chatId || snapshot?.meta?.chatId);
|
|
const nowMs = Date.now();
|
|
|
|
const nodes = sanitizeSnapshotRecordArray(snapshot?.nodes);
|
|
const edges = sanitizeSnapshotRecordArray(snapshot?.edges);
|
|
const tombstones = sanitizeSnapshotRecordArray(snapshot?.tombstones);
|
|
|
|
const state = {
|
|
lastProcessedFloor: Number.isFinite(Number(snapshot?.state?.lastProcessedFloor))
|
|
? Number(snapshot.state.lastProcessedFloor)
|
|
: -1,
|
|
extractionCount: Number.isFinite(Number(snapshot?.state?.extractionCount))
|
|
? Number(snapshot.state.extractionCount)
|
|
: 0,
|
|
};
|
|
|
|
const incomingMeta =
|
|
snapshot?.meta && typeof snapshot.meta === "object" && !Array.isArray(snapshot.meta)
|
|
? { ...snapshot.meta }
|
|
: {};
|
|
|
|
const meta = {
|
|
...incomingMeta,
|
|
schemaVersion: Number.isFinite(Number(incomingMeta.schemaVersion))
|
|
? Number(incomingMeta.schemaVersion)
|
|
: 1,
|
|
chatId: normalizedChatId,
|
|
deviceId: String(incomingMeta.deviceId || "").trim(),
|
|
revision: normalizeRevision(incomingMeta.revision),
|
|
lastModified: normalizeTimestamp(incomingMeta.lastModified, nowMs),
|
|
nodeCount: nodes.length,
|
|
edgeCount: edges.length,
|
|
tombstoneCount: tombstones.length,
|
|
};
|
|
|
|
return {
|
|
meta,
|
|
nodes,
|
|
edges,
|
|
tombstones,
|
|
state,
|
|
};
|
|
}
|
|
|
|
function buildRemoteChunkFilename(baseFilename, kind, index, payload) {
|
|
const normalizedBase = String(baseFilename || "sync.json").replace(/\.json$/i, "");
|
|
const normalizedKind = String(kind || "chunk").trim().toLowerCase() || "chunk";
|
|
const serialized = JSON.stringify(payload);
|
|
const hash = createStableFilenameHash(`${normalizedBase}:${normalizedKind}:${serialized}`);
|
|
return `${normalizedBase}.__${normalizedKind}.${String(index).padStart(3, "0")}.${hash}.json`;
|
|
}
|
|
|
|
function chunkArray(records = [], chunkSize = 1000) {
|
|
const normalizedRecords = Array.isArray(records) ? records : [];
|
|
const normalizedChunkSize = Math.max(1, Math.floor(Number(chunkSize) || 1));
|
|
const chunks = [];
|
|
for (let index = 0; index < normalizedRecords.length; index += normalizedChunkSize) {
|
|
chunks.push(normalizedRecords.slice(index, index + normalizedChunkSize));
|
|
}
|
|
return chunks;
|
|
}
|
|
|
|
function buildRemoteSyncEnvelopeV2(snapshot = {}, chatId = "", filename = "") {
|
|
const normalizedSnapshot = normalizeSyncSnapshot(snapshot, chatId);
|
|
const runtimeMeta = toSerializableData(normalizedSnapshot.meta, {});
|
|
const manifestMeta = {
|
|
chatId: normalizedSnapshot.meta.chatId,
|
|
revision: normalizeRevision(normalizedSnapshot.meta.revision),
|
|
lastModified: normalizeTimestamp(normalizedSnapshot.meta.lastModified, 0),
|
|
deviceId: String(normalizedSnapshot.meta.deviceId || "").trim(),
|
|
nodeCount: normalizedSnapshot.nodes.length,
|
|
edgeCount: normalizedSnapshot.edges.length,
|
|
tombstoneCount: normalizedSnapshot.tombstones.length,
|
|
schemaVersion: normalizeNonNegativeInteger(normalizedSnapshot.meta.schemaVersion, 1),
|
|
};
|
|
const chunkSpecs = [
|
|
...chunkArray(normalizedSnapshot.nodes, BME_REMOTE_SYNC_NODE_CHUNK_SIZE).map(
|
|
(records, index) => ({ kind: "nodes", records, index }),
|
|
),
|
|
...chunkArray(normalizedSnapshot.edges, BME_REMOTE_SYNC_EDGE_CHUNK_SIZE).map(
|
|
(records, index) => ({ kind: "edges", records, index }),
|
|
),
|
|
...chunkArray(
|
|
normalizedSnapshot.tombstones,
|
|
BME_REMOTE_SYNC_TOMBSTONE_CHUNK_SIZE,
|
|
).map((records, index) => ({ kind: "tombstones", records, index })),
|
|
{
|
|
kind: "runtime-meta",
|
|
records: [runtimeMeta],
|
|
index: 0,
|
|
},
|
|
];
|
|
const chunks = chunkSpecs.map((chunk) => {
|
|
const payload = {
|
|
kind: chunk.kind,
|
|
index: chunk.index,
|
|
records: toSerializableData(chunk.records, []),
|
|
};
|
|
const chunkFilename = buildRemoteChunkFilename(
|
|
filename,
|
|
chunk.kind,
|
|
chunk.index,
|
|
payload,
|
|
);
|
|
return {
|
|
kind: chunk.kind,
|
|
index: chunk.index,
|
|
count: Array.isArray(chunk.records) ? chunk.records.length : 0,
|
|
filename: chunkFilename,
|
|
payload,
|
|
};
|
|
});
|
|
return {
|
|
manifest: {
|
|
kind: "st-bme-sync",
|
|
formatVersion: BME_REMOTE_SYNC_FORMAT_VERSION_V2,
|
|
chatId: normalizedSnapshot.meta.chatId,
|
|
meta: manifestMeta,
|
|
state: toSerializableData(normalizedSnapshot.state, {
|
|
lastProcessedFloor: -1,
|
|
extractionCount: 0,
|
|
}),
|
|
chunks: chunks.map((chunk) => ({
|
|
kind: chunk.kind,
|
|
index: chunk.index,
|
|
count: chunk.count,
|
|
filename: chunk.filename,
|
|
})),
|
|
},
|
|
chunks,
|
|
};
|
|
}
|
|
|
|
function markBackendVectorSnapshotDirty(
|
|
snapshot = {},
|
|
reason = "backend-sync-import-unverified",
|
|
warning = "后端向量索引需要在当前环境重建",
|
|
) {
|
|
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) {
|
|
return snapshot;
|
|
}
|
|
|
|
if (!snapshot.meta || typeof snapshot.meta !== "object" || Array.isArray(snapshot.meta)) {
|
|
return snapshot;
|
|
}
|
|
|
|
const vectorMeta = normalizeRuntimeVectorMeta(
|
|
snapshot.meta?.[RUNTIME_VECTOR_META_KEY],
|
|
);
|
|
if (vectorMeta.mode !== "backend") {
|
|
return snapshot;
|
|
}
|
|
|
|
const total = Math.max(
|
|
normalizeNonNegativeInteger(vectorMeta.lastStats?.total, 0),
|
|
Object.keys(vectorMeta.nodeToHash || {}).length,
|
|
Object.keys(vectorMeta.hashToNodeId || {}).length,
|
|
);
|
|
const pending = total > 0
|
|
? Math.max(1, normalizeNonNegativeInteger(vectorMeta.lastStats?.pending, 0))
|
|
: normalizeNonNegativeInteger(vectorMeta.lastStats?.pending, 0);
|
|
|
|
snapshot.meta[RUNTIME_VECTOR_META_KEY] = {
|
|
...vectorMeta,
|
|
hashToNodeId: {},
|
|
nodeToHash: {},
|
|
replayRequiredNodeIds: [],
|
|
dirty: true,
|
|
dirtyReason: String(reason || "backend-sync-import-unverified"),
|
|
pendingRepairFromFloor: 0,
|
|
lastStats: {
|
|
total,
|
|
indexed: 0,
|
|
stale: total,
|
|
pending,
|
|
},
|
|
lastWarning: String(warning || "后端向量索引需要在当前环境重建"),
|
|
};
|
|
return snapshot;
|
|
}
|
|
|
|
function createRecordWinnerByUpdatedAt(localRecord, remoteRecord) {
|
|
if (!localRecord) return remoteRecord || null;
|
|
if (!remoteRecord) return localRecord || null;
|
|
|
|
const localUpdatedAt = normalizeTimestamp(localRecord.updatedAt, 0);
|
|
const remoteUpdatedAt = normalizeTimestamp(remoteRecord.updatedAt, 0);
|
|
|
|
if (remoteUpdatedAt > localUpdatedAt) {
|
|
return remoteRecord;
|
|
}
|
|
|
|
if (localUpdatedAt > remoteUpdatedAt) {
|
|
return localRecord;
|
|
}
|
|
|
|
return remoteRecord;
|
|
}
|
|
|
|
function buildTombstoneIndex(tombstones = []) {
|
|
const tombstoneById = new Map();
|
|
const tombstoneByTarget = new Map();
|
|
|
|
for (const tombstone of tombstones) {
|
|
if (!tombstone || typeof tombstone !== "object") continue;
|
|
|
|
const normalizedTombstone = {
|
|
...tombstone,
|
|
id: String(tombstone.id || "").trim(),
|
|
kind: String(tombstone.kind || "").trim(),
|
|
targetId: String(tombstone.targetId || "").trim(),
|
|
sourceDeviceId: String(tombstone.sourceDeviceId || "").trim(),
|
|
deletedAt: normalizeTimestamp(tombstone.deletedAt, 0),
|
|
};
|
|
|
|
if (!normalizedTombstone.id) continue;
|
|
|
|
const existingById = tombstoneById.get(normalizedTombstone.id);
|
|
if (!existingById || normalizedTombstone.deletedAt >= existingById.deletedAt) {
|
|
tombstoneById.set(normalizedTombstone.id, normalizedTombstone);
|
|
}
|
|
|
|
if (normalizedTombstone.kind && normalizedTombstone.targetId) {
|
|
const targetKey = `${normalizedTombstone.kind}:${normalizedTombstone.targetId}`;
|
|
const existingByTarget = tombstoneByTarget.get(targetKey);
|
|
if (!existingByTarget || normalizedTombstone.deletedAt >= existingByTarget.deletedAt) {
|
|
tombstoneByTarget.set(targetKey, normalizedTombstone);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
byId: tombstoneById,
|
|
byTarget: tombstoneByTarget,
|
|
};
|
|
}
|
|
|
|
function filterRecordsByTombstones(records = [], kind, tombstoneIndex) {
|
|
const normalizedKind = String(kind || "").trim();
|
|
if (!normalizedKind || !tombstoneIndex?.byTarget) return records;
|
|
|
|
return records.filter((record) => {
|
|
const recordId = String(record?.id || "").trim();
|
|
if (!recordId) return false;
|
|
|
|
const targetKey = `${normalizedKind}:${recordId}`;
|
|
const tombstone = tombstoneIndex.byTarget.get(targetKey);
|
|
if (!tombstone) return true;
|
|
|
|
const deletedAt = normalizeTimestamp(tombstone.deletedAt, 0);
|
|
const updatedAt = normalizeTimestamp(record?.updatedAt, 0);
|
|
return deletedAt <= updatedAt;
|
|
});
|
|
}
|
|
|
|
function mergeRecordCollectionById(localRecords = [], remoteRecords = []) {
|
|
const mergedById = new Map();
|
|
|
|
for (const record of localRecords) {
|
|
const id = String(record?.id || "").trim();
|
|
if (!id) continue;
|
|
mergedById.set(id, { ...record, id });
|
|
}
|
|
|
|
for (const record of remoteRecords) {
|
|
const id = String(record?.id || "").trim();
|
|
if (!id) continue;
|
|
|
|
const localRecord = mergedById.get(id) || null;
|
|
const remoteRecord = { ...record, id };
|
|
const winner = createRecordWinnerByUpdatedAt(localRecord, remoteRecord);
|
|
if (winner) mergedById.set(id, winner);
|
|
}
|
|
|
|
return Array.from(mergedById.values());
|
|
}
|
|
|
|
function normalizeNonNegativeInteger(value, fallback = 0) {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
return Math.max(0, Math.floor(Number(fallback) || 0));
|
|
}
|
|
return Math.floor(parsed);
|
|
}
|
|
|
|
function normalizeOptionalFloor(value) {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed)) return null;
|
|
return Math.max(0, Math.floor(parsed));
|
|
}
|
|
|
|
function normalizeStringMap(record = {}) {
|
|
if (!record || typeof record !== "object" || Array.isArray(record)) {
|
|
return {};
|
|
}
|
|
|
|
const normalized = {};
|
|
for (const [key, value] of Object.entries(record)) {
|
|
const normalizedKey = String(key || "").trim();
|
|
const normalizedValue = String(value || "").trim();
|
|
if (!normalizedKey || !normalizedValue) continue;
|
|
normalized[normalizedKey] = normalizedValue;
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function normalizeProcessedMessageHashes(record = {}) {
|
|
if (!record || typeof record !== "object" || Array.isArray(record)) {
|
|
return {};
|
|
}
|
|
|
|
const normalized = {};
|
|
for (const [floorKey, hashValue] of Object.entries(record)) {
|
|
const floor = Number.parseInt(floorKey, 10);
|
|
const normalizedHash = String(hashValue || "").trim();
|
|
if (!Number.isFinite(floor) || floor < 0 || !normalizedHash) continue;
|
|
normalized[String(floor)] = normalizedHash;
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function sortProcessedMessageHashes(record = {}) {
|
|
const sorted = {};
|
|
const keys = Object.keys(record)
|
|
.map((value) => Number.parseInt(value, 10))
|
|
.filter(Number.isFinite)
|
|
.sort((left, right) => left - right);
|
|
|
|
for (const key of keys) {
|
|
sorted[String(key)] = record[String(key)];
|
|
}
|
|
return sorted;
|
|
}
|
|
|
|
function normalizeStringArray(value) {
|
|
return Array.isArray(value)
|
|
? [...new Set(value.map((item) => String(item || "").trim()).filter(Boolean))]
|
|
: [];
|
|
}
|
|
|
|
function stableSerialize(value) {
|
|
try {
|
|
return JSON.stringify(value);
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function readRuntimeTimestamp(value) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
return 0;
|
|
}
|
|
|
|
const candidates = [
|
|
value.updatedAt,
|
|
value.at,
|
|
value.createdAt,
|
|
value.completedAt,
|
|
value.lastUpdatedAt,
|
|
value.timestamp,
|
|
];
|
|
|
|
for (const candidate of candidates) {
|
|
const parsed = Number(candidate);
|
|
if (Number.isFinite(parsed) && parsed > 0) {
|
|
return Math.floor(parsed);
|
|
}
|
|
|
|
if (typeof candidate === "string") {
|
|
const dateValue = Date.parse(candidate);
|
|
if (Number.isFinite(dateValue) && dateValue > 0) {
|
|
return Math.floor(dateValue);
|
|
}
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
function chooseNewerRuntimePayload(localValue, remoteValue) {
|
|
const local = toSerializableData(localValue, null);
|
|
const remote = toSerializableData(remoteValue, null);
|
|
|
|
if (local == null) return remote;
|
|
if (remote == null) return local;
|
|
|
|
if (stableSerialize(local) === stableSerialize(remote)) {
|
|
return local;
|
|
}
|
|
|
|
const localTimestamp = readRuntimeTimestamp(local);
|
|
const remoteTimestamp = readRuntimeTimestamp(remote);
|
|
if (remoteTimestamp > localTimestamp) return remote;
|
|
if (localTimestamp > remoteTimestamp) return local;
|
|
|
|
return null;
|
|
}
|
|
|
|
function pickMinFinite(values = [], fallbackValue = null) {
|
|
const normalized = values.filter(Number.isFinite);
|
|
if (!normalized.length) return fallbackValue;
|
|
return Math.min(...normalized);
|
|
}
|
|
|
|
function normalizeRuntimeHistoryMeta(value = {}, fallbackChatId = "") {
|
|
const input =
|
|
value && typeof value === "object" && !Array.isArray(value)
|
|
? toSerializableData(value, {})
|
|
: {};
|
|
const processedMessageHashVersion = Number.isFinite(
|
|
Number(input.processedMessageHashVersion),
|
|
)
|
|
? Math.max(1, Math.floor(Number(input.processedMessageHashVersion)))
|
|
: PROCESSED_MESSAGE_HASH_VERSION;
|
|
const processedMessageHashesNeedRefresh =
|
|
input.processedMessageHashesNeedRefresh === true ||
|
|
processedMessageHashVersion !== PROCESSED_MESSAGE_HASH_VERSION;
|
|
|
|
return {
|
|
...input,
|
|
chatId: normalizeChatId(input.chatId || fallbackChatId),
|
|
lastProcessedAssistantFloor: Number.isFinite(Number(input.lastProcessedAssistantFloor))
|
|
? Math.floor(Number(input.lastProcessedAssistantFloor))
|
|
: -1,
|
|
processedMessageHashVersion: PROCESSED_MESSAGE_HASH_VERSION,
|
|
extractionCount: normalizeNonNegativeInteger(input.extractionCount, 0),
|
|
processedMessageHashes: processedMessageHashesNeedRefresh
|
|
? {}
|
|
: normalizeProcessedMessageHashes(input.processedMessageHashes),
|
|
processedMessageHashesNeedRefresh,
|
|
historyDirtyFrom: normalizeOptionalFloor(input.historyDirtyFrom),
|
|
lastMutationReason:
|
|
typeof input.lastMutationReason === "string" ? input.lastMutationReason : "",
|
|
lastMutationSource:
|
|
typeof input.lastMutationSource === "string" ? input.lastMutationSource : "",
|
|
lastRecoveryResult: toSerializableData(input.lastRecoveryResult, null),
|
|
lastBatchStatus: toSerializableData(input.lastBatchStatus, null),
|
|
};
|
|
}
|
|
|
|
function resolveEarliestRetainedBatchFloor(journals = []) {
|
|
let earliestFloor = null;
|
|
for (const journal of Array.isArray(journals) ? journals : []) {
|
|
const range = Array.isArray(journal?.processedRange)
|
|
? journal.processedRange
|
|
: [];
|
|
const startFloor = Number(range[0]);
|
|
if (!Number.isFinite(startFloor)) continue;
|
|
const normalizedFloor = Math.max(0, Math.floor(startFloor));
|
|
earliestFloor =
|
|
earliestFloor == null
|
|
? normalizedFloor
|
|
: Math.min(earliestFloor, normalizedFloor);
|
|
}
|
|
return earliestFloor;
|
|
}
|
|
|
|
function buildManualBackupSnapshot(snapshot = {}, chatId = "") {
|
|
const normalizedSnapshot = normalizeSyncSnapshot(snapshot, chatId);
|
|
const meta = toSerializableData(normalizedSnapshot.meta, {});
|
|
const originalBatchJournal = Array.isArray(meta[RUNTIME_BATCH_JOURNAL_META_KEY])
|
|
? toSerializableData(meta[RUNTIME_BATCH_JOURNAL_META_KEY], [])
|
|
: [];
|
|
const retainedBatchJournal = originalBatchJournal.slice(
|
|
-MANUAL_BACKUP_BATCH_JOURNAL_LIMIT,
|
|
);
|
|
const historyState = normalizeRuntimeHistoryMeta(
|
|
meta[RUNTIME_HISTORY_META_KEY],
|
|
chatId,
|
|
);
|
|
|
|
historyState[MANUAL_BACKUP_BATCH_JOURNAL_COVERAGE_KEY] = {
|
|
truncated: originalBatchJournal.length > retainedBatchJournal.length,
|
|
earliestRetainedFloor: resolveEarliestRetainedBatchFloor(retainedBatchJournal),
|
|
retainedCount: retainedBatchJournal.length,
|
|
};
|
|
|
|
meta[RUNTIME_HISTORY_META_KEY] = historyState;
|
|
meta[RUNTIME_BATCH_JOURNAL_META_KEY] = retainedBatchJournal;
|
|
meta[RUNTIME_MAINTENANCE_JOURNAL_META_KEY] = [];
|
|
|
|
return {
|
|
meta,
|
|
nodes: toSerializableData(normalizedSnapshot.nodes, []),
|
|
edges: toSerializableData(normalizedSnapshot.edges, []),
|
|
tombstones: toSerializableData(normalizedSnapshot.tombstones, []),
|
|
state: toSerializableData(normalizedSnapshot.state, {
|
|
lastProcessedFloor: -1,
|
|
extractionCount: 0,
|
|
}),
|
|
};
|
|
}
|
|
|
|
function markManualBackupHistoryForLocalRebind(snapshot = {}, chatId = "") {
|
|
const normalizedSnapshot = normalizeSyncSnapshot(snapshot, chatId);
|
|
const meta = toSerializableData(normalizedSnapshot.meta, {});
|
|
const historyState = normalizeRuntimeHistoryMeta(
|
|
meta[RUNTIME_HISTORY_META_KEY],
|
|
chatId,
|
|
);
|
|
const lastProcessedAssistantFloor = Number(
|
|
historyState.lastProcessedAssistantFloor,
|
|
);
|
|
|
|
historyState.processedMessageHashes = {};
|
|
historyState.processedMessageHashesNeedRefresh =
|
|
Number.isFinite(lastProcessedAssistantFloor) &&
|
|
lastProcessedAssistantFloor >= 0;
|
|
historyState.historyDirtyFrom = null;
|
|
historyState.lastMutationReason = "";
|
|
historyState.lastMutationSource = "";
|
|
historyState.lastRecoveryResult = null;
|
|
meta[RUNTIME_HISTORY_META_KEY] = historyState;
|
|
|
|
return {
|
|
meta,
|
|
nodes: toSerializableData(normalizedSnapshot.nodes, []),
|
|
edges: toSerializableData(normalizedSnapshot.edges, []),
|
|
tombstones: toSerializableData(normalizedSnapshot.tombstones, []),
|
|
state: toSerializableData(normalizedSnapshot.state, {
|
|
lastProcessedFloor: -1,
|
|
extractionCount: 0,
|
|
}),
|
|
};
|
|
}
|
|
|
|
function mergeRuntimeHistoryMeta(localMeta = {}, remoteMeta = {}, options = {}) {
|
|
const localHistory = normalizeRuntimeHistoryMeta(localMeta, options.chatId);
|
|
const remoteHistory = normalizeRuntimeHistoryMeta(remoteMeta, options.chatId);
|
|
|
|
const fallbackLastProcessedFloor = Number.isFinite(Number(options.fallbackLastProcessedFloor))
|
|
? Math.floor(Number(options.fallbackLastProcessedFloor))
|
|
: -1;
|
|
const fallbackExtractionCount = normalizeNonNegativeInteger(options.fallbackExtractionCount, 0);
|
|
|
|
const baseLastProcessedFloor = Math.max(
|
|
localHistory.lastProcessedAssistantFloor,
|
|
remoteHistory.lastProcessedAssistantFloor,
|
|
fallbackLastProcessedFloor,
|
|
);
|
|
|
|
const mergedHashes = {};
|
|
const conflictFloors = [];
|
|
const floorSet = new Set([
|
|
...Object.keys(localHistory.processedMessageHashes),
|
|
...Object.keys(remoteHistory.processedMessageHashes),
|
|
]);
|
|
const sortedFloors = Array.from(floorSet)
|
|
.map((value) => Number.parseInt(value, 10))
|
|
.filter(Number.isFinite)
|
|
.sort((left, right) => left - right);
|
|
|
|
for (const floor of sortedFloors) {
|
|
const floorKey = String(floor);
|
|
const localHash = localHistory.processedMessageHashes[floorKey];
|
|
const remoteHash = remoteHistory.processedMessageHashes[floorKey];
|
|
if (localHash && remoteHash && localHash !== remoteHash) {
|
|
conflictFloors.push(floor);
|
|
continue;
|
|
}
|
|
if (localHash || remoteHash) {
|
|
mergedHashes[floorKey] = localHash || remoteHash;
|
|
}
|
|
}
|
|
|
|
let safeLastProcessedFloor = baseLastProcessedFloor;
|
|
const hasIntegrityConflict = conflictFloors.length > 0;
|
|
if (hasIntegrityConflict) {
|
|
const highestConflictFreeFloor = sortedFloors.length
|
|
? sortedFloors[sortedFloors.length - 1]
|
|
: -1;
|
|
const firstConflictFloor = Math.min(...conflictFloors);
|
|
safeLastProcessedFloor = Math.min(
|
|
baseLastProcessedFloor,
|
|
highestConflictFreeFloor,
|
|
firstConflictFloor - 1,
|
|
);
|
|
}
|
|
safeLastProcessedFloor = Math.max(-1, safeLastProcessedFloor);
|
|
|
|
const historyDirtyFrom = pickMinFinite(
|
|
[
|
|
localHistory.historyDirtyFrom,
|
|
remoteHistory.historyDirtyFrom,
|
|
hasIntegrityConflict ? Math.max(0, safeLastProcessedFloor + 1) : null,
|
|
],
|
|
null,
|
|
);
|
|
|
|
const firstConflictFloor = hasIntegrityConflict ? Math.min(...conflictFloors) : null;
|
|
const mergedHistory = {
|
|
...localHistory,
|
|
...remoteHistory,
|
|
chatId: normalizeChatId(remoteHistory.chatId || localHistory.chatId || options.chatId),
|
|
lastProcessedAssistantFloor: safeLastProcessedFloor,
|
|
processedMessageHashVersion: PROCESSED_MESSAGE_HASH_VERSION,
|
|
extractionCount: Math.max(
|
|
localHistory.extractionCount,
|
|
remoteHistory.extractionCount,
|
|
fallbackExtractionCount,
|
|
),
|
|
processedMessageHashes: sortProcessedMessageHashes(mergedHashes),
|
|
processedMessageHashesNeedRefresh:
|
|
localHistory.processedMessageHashesNeedRefresh === true ||
|
|
remoteHistory.processedMessageHashesNeedRefresh === true,
|
|
historyDirtyFrom,
|
|
lastMutationReason: hasIntegrityConflict
|
|
? `sync-merge:processed-hash-conflict@${firstConflictFloor}`
|
|
: String(remoteHistory.lastMutationReason || localHistory.lastMutationReason || ""),
|
|
lastMutationSource: hasIntegrityConflict
|
|
? "sync-merge"
|
|
: String(remoteHistory.lastMutationSource || localHistory.lastMutationSource || ""),
|
|
lastRecoveryResult: chooseNewerRuntimePayload(
|
|
localHistory.lastRecoveryResult,
|
|
remoteHistory.lastRecoveryResult,
|
|
),
|
|
lastBatchStatus: chooseNewerRuntimePayload(
|
|
localHistory.lastBatchStatus,
|
|
remoteHistory.lastBatchStatus,
|
|
),
|
|
};
|
|
|
|
return {
|
|
history: mergedHistory,
|
|
hasIntegrityConflict,
|
|
safeLastProcessedFloor,
|
|
conflictFloors,
|
|
};
|
|
}
|
|
|
|
function normalizeRuntimeVectorMeta(value = {}) {
|
|
const input =
|
|
value && typeof value === "object" && !Array.isArray(value)
|
|
? toSerializableData(value, {})
|
|
: {};
|
|
|
|
const localStats =
|
|
input.lastStats && typeof input.lastStats === "object" && !Array.isArray(input.lastStats)
|
|
? input.lastStats
|
|
: {};
|
|
|
|
return {
|
|
...input,
|
|
mode: typeof input.mode === "string" ? input.mode : "",
|
|
collectionId: typeof input.collectionId === "string" ? input.collectionId : "",
|
|
source: typeof input.source === "string" ? input.source : "",
|
|
modelScope: typeof input.modelScope === "string" ? input.modelScope : "",
|
|
hashToNodeId: normalizeStringMap(input.hashToNodeId),
|
|
nodeToHash: normalizeStringMap(input.nodeToHash),
|
|
dirty: Boolean(input.dirty),
|
|
replayRequiredNodeIds: normalizeStringArray(input.replayRequiredNodeIds),
|
|
dirtyReason: typeof input.dirtyReason === "string" ? input.dirtyReason : "",
|
|
pendingRepairFromFloor: normalizeOptionalFloor(input.pendingRepairFromFloor),
|
|
lastSyncAt: normalizeTimestamp(input.lastSyncAt, 0),
|
|
lastStats: {
|
|
total: normalizeNonNegativeInteger(localStats.total, 0),
|
|
indexed: normalizeNonNegativeInteger(localStats.indexed, 0),
|
|
stale: normalizeNonNegativeInteger(localStats.stale, 0),
|
|
pending: normalizeNonNegativeInteger(localStats.pending, 0),
|
|
},
|
|
lastWarning: typeof input.lastWarning === "string" ? input.lastWarning : "",
|
|
};
|
|
}
|
|
|
|
function mergeRuntimeVectorMeta(localMeta = {}, remoteMeta = {}, options = {}) {
|
|
const localVector = normalizeRuntimeVectorMeta(localMeta);
|
|
const remoteVector = normalizeRuntimeVectorMeta(remoteMeta);
|
|
|
|
const aliveNodeIds = new Set(
|
|
(Array.isArray(options.mergedNodes) ? options.mergedNodes : [])
|
|
.map((node) => String(node?.id || "").trim())
|
|
.filter(Boolean),
|
|
);
|
|
|
|
const conflictNodeIds = new Set();
|
|
const candidateHashByNode = new Map();
|
|
const registerCandidate = (nodeId, hash) => {
|
|
const normalizedNodeId = String(nodeId || "").trim();
|
|
const normalizedHash = String(hash || "").trim();
|
|
if (!normalizedNodeId || !normalizedHash || !aliveNodeIds.has(normalizedNodeId)) return;
|
|
if (conflictNodeIds.has(normalizedNodeId)) return;
|
|
const existingHash = candidateHashByNode.get(normalizedNodeId);
|
|
if (!existingHash) {
|
|
candidateHashByNode.set(normalizedNodeId, normalizedHash);
|
|
return;
|
|
}
|
|
if (existingHash !== normalizedHash) {
|
|
conflictNodeIds.add(normalizedNodeId);
|
|
candidateHashByNode.delete(normalizedNodeId);
|
|
}
|
|
};
|
|
|
|
for (const [nodeId, hash] of Object.entries(localVector.nodeToHash)) {
|
|
registerCandidate(nodeId, hash);
|
|
}
|
|
for (const [nodeId, hash] of Object.entries(remoteVector.nodeToHash)) {
|
|
registerCandidate(nodeId, hash);
|
|
}
|
|
for (const [hash, nodeId] of Object.entries(localVector.hashToNodeId)) {
|
|
registerCandidate(nodeId, hash);
|
|
}
|
|
for (const [hash, nodeId] of Object.entries(remoteVector.hashToNodeId)) {
|
|
registerCandidate(nodeId, hash);
|
|
}
|
|
|
|
for (const nodeId of conflictNodeIds) {
|
|
candidateHashByNode.delete(nodeId);
|
|
}
|
|
|
|
const hashBuckets = new Map();
|
|
for (const [nodeId, hash] of candidateHashByNode.entries()) {
|
|
const bucket = hashBuckets.get(hash) || new Set();
|
|
bucket.add(nodeId);
|
|
hashBuckets.set(hash, bucket);
|
|
}
|
|
|
|
const mergedNodeToHash = {};
|
|
const mergedHashToNodeId = {};
|
|
for (const [hash, bucket] of hashBuckets.entries()) {
|
|
const nodeIds = Array.from(bucket).filter((nodeId) => aliveNodeIds.has(nodeId));
|
|
if (nodeIds.length !== 1) {
|
|
for (const nodeId of nodeIds) {
|
|
conflictNodeIds.add(nodeId);
|
|
}
|
|
continue;
|
|
}
|
|
const nodeId = nodeIds[0];
|
|
mergedNodeToHash[nodeId] = hash;
|
|
mergedHashToNodeId[hash] = nodeId;
|
|
}
|
|
|
|
const replayRequiredNodeIds = normalizeStringArray([
|
|
...localVector.replayRequiredNodeIds,
|
|
...remoteVector.replayRequiredNodeIds,
|
|
...Array.from(conflictNodeIds),
|
|
]).filter((nodeId) => aliveNodeIds.has(nodeId));
|
|
|
|
const hasMappingConflict = conflictNodeIds.size > 0;
|
|
const inheritedDirty = Boolean(localVector.dirty || remoteVector.dirty);
|
|
const dirty = inheritedDirty || hasMappingConflict || replayRequiredNodeIds.length > 0;
|
|
const fallbackRepairFloor = Number.isFinite(Number(options.fallbackLastProcessedFloor))
|
|
? Math.max(0, Math.floor(Number(options.fallbackLastProcessedFloor)))
|
|
: 0;
|
|
|
|
const pendingRepairFromFloor = dirty
|
|
? pickMinFinite(
|
|
[
|
|
localVector.pendingRepairFromFloor,
|
|
remoteVector.pendingRepairFromFloor,
|
|
hasMappingConflict ? fallbackRepairFloor : null,
|
|
],
|
|
null,
|
|
)
|
|
: null;
|
|
|
|
const mappingCount = Object.keys(mergedNodeToHash).length;
|
|
const total = Math.max(mappingCount, localVector.lastStats.total, remoteVector.lastStats.total);
|
|
const indexed = mappingCount;
|
|
const stale = Math.max(0, total - indexed);
|
|
const pending = dirty
|
|
? Math.max(
|
|
replayRequiredNodeIds.length,
|
|
localVector.lastStats.pending,
|
|
remoteVector.lastStats.pending,
|
|
hasMappingConflict ? 1 : 0,
|
|
)
|
|
: 0;
|
|
|
|
return {
|
|
...localVector,
|
|
...remoteVector,
|
|
mode: String(remoteVector.mode || localVector.mode || "").trim(),
|
|
source: String(remoteVector.source || localVector.source || "").trim(),
|
|
modelScope: String(remoteVector.modelScope || localVector.modelScope || "").trim(),
|
|
collectionId: String(remoteVector.collectionId || localVector.collectionId || "").trim(),
|
|
hashToNodeId: mergedHashToNodeId,
|
|
nodeToHash: mergedNodeToHash,
|
|
replayRequiredNodeIds,
|
|
dirty,
|
|
dirtyReason: hasMappingConflict
|
|
? "sync-merge-vector-conflict"
|
|
: dirty
|
|
? String(
|
|
remoteVector.dirtyReason ||
|
|
localVector.dirtyReason ||
|
|
"sync-merge-vector-replay-required",
|
|
)
|
|
: "",
|
|
pendingRepairFromFloor,
|
|
lastSyncAt: Math.max(localVector.lastSyncAt, remoteVector.lastSyncAt),
|
|
lastStats: {
|
|
total,
|
|
indexed,
|
|
stale,
|
|
pending,
|
|
},
|
|
lastWarning: hasMappingConflict
|
|
? "同步合并检测到向量映射冲突,已标记待重建"
|
|
: String(remoteVector.lastWarning || localVector.lastWarning || ""),
|
|
};
|
|
}
|
|
|
|
function normalizeJournalEntry(entry) {
|
|
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null;
|
|
const normalizedId = String(entry.id || "").trim();
|
|
if (!normalizedId) return null;
|
|
|
|
const range = Array.isArray(entry.processedRange) ? entry.processedRange : [];
|
|
const rangeStart = Number(range[0]);
|
|
const rangeEnd = Number(range[1]);
|
|
if (!Number.isFinite(rangeStart) || !Number.isFinite(rangeEnd) || rangeStart > rangeEnd) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
...toSerializableData(entry, entry),
|
|
id: normalizedId,
|
|
createdAt: normalizeTimestamp(entry.createdAt ?? entry.at, 0),
|
|
processedRange: [Math.floor(rangeStart), Math.floor(rangeEnd)],
|
|
};
|
|
}
|
|
|
|
function chooseJournalEntryWinner(localEntry, remoteEntry) {
|
|
if (!localEntry) return remoteEntry || null;
|
|
if (!remoteEntry) return localEntry || null;
|
|
|
|
if (remoteEntry.createdAt > localEntry.createdAt) return remoteEntry;
|
|
if (localEntry.createdAt > remoteEntry.createdAt) return localEntry;
|
|
|
|
const localEnd = Number(localEntry.processedRange?.[1] ?? -1);
|
|
const remoteEnd = Number(remoteEntry.processedRange?.[1] ?? -1);
|
|
if (remoteEnd > localEnd) return remoteEntry;
|
|
if (localEnd > remoteEnd) return localEntry;
|
|
return remoteEntry;
|
|
}
|
|
|
|
function mergeRuntimeBatchJournal(localJournal = [], remoteJournal = [], options = {}) {
|
|
const journalById = new Map();
|
|
const register = (entry) => {
|
|
const normalizedEntry = normalizeJournalEntry(entry);
|
|
if (!normalizedEntry) return;
|
|
const existing = journalById.get(normalizedEntry.id);
|
|
const winner = chooseJournalEntryWinner(existing, normalizedEntry);
|
|
if (winner) journalById.set(normalizedEntry.id, winner);
|
|
};
|
|
|
|
for (const entry of Array.isArray(localJournal) ? localJournal : []) {
|
|
register(entry);
|
|
}
|
|
for (const entry of Array.isArray(remoteJournal) ? remoteJournal : []) {
|
|
register(entry);
|
|
}
|
|
|
|
let merged = Array.from(journalById.values());
|
|
const maxTrustedFloor = Number.isFinite(Number(options.maxTrustedFloor))
|
|
? Math.floor(Number(options.maxTrustedFloor))
|
|
: null;
|
|
if (Number.isFinite(maxTrustedFloor)) {
|
|
merged = merged.filter((entry) => Number(entry.processedRange?.[1]) <= maxTrustedFloor);
|
|
}
|
|
|
|
merged.sort((left, right) => {
|
|
const leftStart = Number(left.processedRange?.[0] ?? -1);
|
|
const rightStart = Number(right.processedRange?.[0] ?? -1);
|
|
const leftEnd = Number(left.processedRange?.[1] ?? -1);
|
|
const rightEnd = Number(right.processedRange?.[1] ?? -1);
|
|
return (
|
|
leftStart - rightStart ||
|
|
leftEnd - rightEnd ||
|
|
left.createdAt - right.createdAt ||
|
|
left.id.localeCompare(right.id)
|
|
);
|
|
});
|
|
|
|
if (merged.length > RUNTIME_BATCH_JOURNAL_LIMIT) {
|
|
merged = merged.slice(-RUNTIME_BATCH_JOURNAL_LIMIT);
|
|
}
|
|
|
|
return merged.map((entry) => toSerializableData(entry, entry));
|
|
}
|
|
|
|
function mergeRuntimeLastRecallResult(localSnapshot, remoteSnapshot) {
|
|
const localRecall = toSerializableData(localSnapshot?.meta?.[RUNTIME_LAST_RECALL_META_KEY], null);
|
|
const remoteRecall = toSerializableData(remoteSnapshot?.meta?.[RUNTIME_LAST_RECALL_META_KEY], null);
|
|
const mergedByPayload = chooseNewerRuntimePayload(localRecall, remoteRecall);
|
|
if (mergedByPayload != null) {
|
|
return mergedByPayload;
|
|
}
|
|
|
|
const localModified = normalizeTimestamp(localSnapshot?.meta?.lastModified, 0);
|
|
const remoteModified = normalizeTimestamp(remoteSnapshot?.meta?.lastModified, 0);
|
|
if (remoteModified > localModified) return remoteRecall;
|
|
if (localModified > remoteModified) return localRecall;
|
|
return null;
|
|
}
|
|
|
|
async function getDb(chatId, options = {}) {
|
|
const normalizedChatId = normalizeChatId(chatId);
|
|
if (!normalizedChatId) {
|
|
throw new Error("chatId 不能为空");
|
|
}
|
|
|
|
if (typeof options.getDb !== "function") {
|
|
throw new Error("同步运行时缺少 getDb(chatId) 能力");
|
|
}
|
|
|
|
const db = await options.getDb(normalizedChatId);
|
|
if (!db || typeof db.exportSnapshot !== "function") {
|
|
throw new Error("getDb(chatId) 必须返回有效的 BmeDatabase 实例");
|
|
}
|
|
|
|
return db;
|
|
}
|
|
|
|
async function readDbMeta(db, key, fallbackValue = null) {
|
|
if (!db || typeof key !== "string" || !key) return fallbackValue;
|
|
if (typeof db.getMeta === "function") {
|
|
return db.getMeta(key, fallbackValue);
|
|
}
|
|
if (db.meta instanceof Map) {
|
|
return db.meta.has(key) ? db.meta.get(key) : fallbackValue;
|
|
}
|
|
if (db.meta && typeof db.meta === "object" && !Array.isArray(db.meta)) {
|
|
return Object.prototype.hasOwnProperty.call(db.meta, key)
|
|
? db.meta[key]
|
|
: fallbackValue;
|
|
}
|
|
return fallbackValue;
|
|
}
|
|
|
|
async function patchDbMeta(db, patch = {}) {
|
|
if (!db || !patch || typeof patch !== "object") return;
|
|
if (typeof db.patchMeta === "function") {
|
|
await db.patchMeta(patch);
|
|
return;
|
|
}
|
|
|
|
for (const [key, value] of Object.entries(patch)) {
|
|
if (typeof db.setMeta === "function") {
|
|
await db.setMeta(key, value);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function invokeSyncAppliedHook(options = {}, payload = {}) {
|
|
if (typeof options.onSyncApplied !== "function") {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await options.onSyncApplied({
|
|
...(payload || {}),
|
|
});
|
|
} catch (error) {
|
|
console.warn("[ST-BME] 同步后运行时刷新回调失败:", {
|
|
chatId: String(payload?.chatId || ""),
|
|
action: String(payload?.action || ""),
|
|
error,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function sanitizeFilename(fileName, options = {}) {
|
|
const finalFallback = normalizeRemoteFilenameCandidate(
|
|
fileName,
|
|
"ST-BME_sync_unknown.json",
|
|
);
|
|
|
|
if (options.disableRemoteSanitize) {
|
|
return finalFallback;
|
|
}
|
|
|
|
try {
|
|
const sanitized = await requestSanitizedFilename(fileName, options);
|
|
return normalizeRemoteFilenameCandidate(sanitized, finalFallback);
|
|
} catch {
|
|
return finalFallback;
|
|
}
|
|
}
|
|
|
|
async function requestSanitizedFilename(fileName, options = {}) {
|
|
if (options.disableRemoteSanitize) {
|
|
return String(fileName || "");
|
|
}
|
|
|
|
const fetchImpl = getFetch(options);
|
|
const response = await fetchImpl("/api/files/sanitize-filename", {
|
|
method: "POST",
|
|
headers: {
|
|
...getRequestHeadersSafe(options),
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ fileName }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
return "";
|
|
}
|
|
|
|
const payload = await response.json().catch(() => null);
|
|
return String(payload?.fileName || "").trim();
|
|
}
|
|
|
|
async function resolveLegacySyncFilename(chatId, options = {}) {
|
|
const normalizedChatId = normalizeChatId(chatId);
|
|
const rawFileName = buildLegacyRawSyncFilename(normalizedChatId);
|
|
const legacyFallback = normalizeLegacyRemoteFilenameCandidate(
|
|
rawFileName,
|
|
"ST-BME_sync_unknown.json",
|
|
);
|
|
|
|
if (options.disableRemoteSanitize) {
|
|
return legacyFallback;
|
|
}
|
|
|
|
try {
|
|
const sanitized = await requestSanitizedFilename(rawFileName, options);
|
|
return normalizeLegacyRemoteFilenameCandidate(sanitized, legacyFallback);
|
|
} catch {
|
|
return legacyFallback;
|
|
}
|
|
}
|
|
|
|
async function resolveSyncFilename(chatId, options = {}) {
|
|
const normalizedChatId = normalizeChatId(chatId);
|
|
if (!normalizedChatId) {
|
|
throw new Error("chatId 不能为空");
|
|
}
|
|
|
|
if (sanitizedFilenameByChatId.has(normalizedChatId)) {
|
|
return sanitizedFilenameByChatId.get(normalizedChatId);
|
|
}
|
|
|
|
const rawFileName = buildSyncFilename(normalizedChatId);
|
|
const sanitized = await sanitizeFilename(rawFileName, options);
|
|
const finalName = normalizeRemoteFilenameCandidate(sanitized, rawFileName);
|
|
rememberResolvedSyncFilename(normalizedChatId, finalName);
|
|
return finalName;
|
|
}
|
|
|
|
async function resolveSyncFilenameCandidates(chatId, options = {}) {
|
|
const normalizedChatId = normalizeChatId(chatId);
|
|
if (!normalizedChatId) {
|
|
throw new Error("chatId 不能为空");
|
|
}
|
|
|
|
const candidates = [];
|
|
const pushCandidate = (value) => {
|
|
const normalizedValue = String(value || "").trim();
|
|
if (!normalizedValue || candidates.includes(normalizedValue)) return;
|
|
candidates.push(normalizedValue);
|
|
};
|
|
|
|
if (sanitizedFilenameByChatId.has(normalizedChatId)) {
|
|
pushCandidate(sanitizedFilenameByChatId.get(normalizedChatId));
|
|
}
|
|
|
|
const primaryRawFileName = buildSyncFilename(normalizedChatId);
|
|
const primarySanitized = await sanitizeFilename(primaryRawFileName, options);
|
|
pushCandidate(
|
|
normalizeRemoteFilenameCandidate(primarySanitized, primaryRawFileName),
|
|
);
|
|
|
|
const legacyRawFileName = buildLegacyRawSyncFilename(normalizedChatId);
|
|
if (legacyRawFileName !== primaryRawFileName) {
|
|
pushCandidate(await resolveLegacySyncFilename(normalizedChatId, options));
|
|
}
|
|
|
|
return candidates;
|
|
}
|
|
|
|
async function readRemoteSnapshot(chatId, options = {}) {
|
|
const readStartedAt = readSyncTimingNow();
|
|
const normalizedChatId = normalizeChatId(chatId);
|
|
if (!normalizedChatId) {
|
|
return {
|
|
exists: false,
|
|
status: "missing-chat-id",
|
|
filename: "",
|
|
snapshot: null,
|
|
timings: finalizeSyncTimings({}, readStartedAt),
|
|
};
|
|
}
|
|
|
|
const fetchImpl = getFetch(options);
|
|
const resolveStartedAt = readSyncTimingNow();
|
|
const candidateFilenames = await resolveSyncFilenameCandidates(
|
|
normalizedChatId,
|
|
options,
|
|
);
|
|
const resolveCandidatesMs = readSyncTimingNow() - resolveStartedAt;
|
|
let lastNotFoundFilename = candidateFilenames[0] || "";
|
|
let networkMs = 0;
|
|
let parseMs = 0;
|
|
let chunkReadMs = 0;
|
|
let normalizeMs = 0;
|
|
|
|
for (const filename of candidateFilenames) {
|
|
const cacheBust = `t=${Date.now()}`;
|
|
const url = `/user/files/${encodeURIComponent(filename)}?${cacheBust}`;
|
|
|
|
let response;
|
|
try {
|
|
const networkStartedAt = readSyncTimingNow();
|
|
response = await fetchImpl(url, {
|
|
method: "GET",
|
|
cache: "no-store",
|
|
});
|
|
networkMs += readSyncTimingNow() - networkStartedAt;
|
|
} catch (error) {
|
|
console.warn("[ST-BME] 读取远端同步文件失败:", error);
|
|
return {
|
|
exists: false,
|
|
status: "network-error",
|
|
filename,
|
|
snapshot: null,
|
|
error,
|
|
timings: finalizeSyncTimings(
|
|
{ resolveCandidatesMs, networkMs, parseMs, chunkReadMs, normalizeMs },
|
|
readStartedAt,
|
|
),
|
|
};
|
|
}
|
|
|
|
if (response.status === 404) {
|
|
lastNotFoundFilename = filename;
|
|
continue;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text().catch(() => response.statusText);
|
|
const error = new Error(errorText || `HTTP ${response.status}`);
|
|
console.warn("[ST-BME] 读取远端同步文件失败:", error);
|
|
return {
|
|
exists: false,
|
|
status: "http-error",
|
|
filename,
|
|
snapshot: null,
|
|
error,
|
|
statusCode: response.status,
|
|
timings: finalizeSyncTimings(
|
|
{ resolveCandidatesMs, networkMs, parseMs, chunkReadMs, normalizeMs },
|
|
readStartedAt,
|
|
),
|
|
};
|
|
}
|
|
|
|
try {
|
|
const parseStartedAt = readSyncTimingNow();
|
|
const remotePayload = await response.json();
|
|
parseMs += readSyncTimingNow() - parseStartedAt;
|
|
let snapshot = null;
|
|
if (Number(remotePayload?.formatVersion || 0) === BME_REMOTE_SYNC_FORMAT_VERSION_V2) {
|
|
const manifestResult = await readRemoteSnapshotV2Manifest(
|
|
remotePayload,
|
|
normalizedChatId,
|
|
{
|
|
...options,
|
|
filename,
|
|
},
|
|
);
|
|
snapshot = manifestResult.snapshot;
|
|
chunkReadMs += Number(manifestResult?.timings?.chunkReadMs || 0);
|
|
normalizeMs += Number(manifestResult?.timings?.normalizeMs || 0);
|
|
} else {
|
|
const normalizeStartedAt = readSyncTimingNow();
|
|
snapshot = normalizeSyncSnapshot(remotePayload, normalizedChatId);
|
|
normalizeMs += readSyncTimingNow() - normalizeStartedAt;
|
|
}
|
|
rememberResolvedSyncFilename(normalizedChatId, filename);
|
|
return {
|
|
exists: true,
|
|
status: "ok",
|
|
filename,
|
|
snapshot,
|
|
timings: finalizeSyncTimings(
|
|
{ resolveCandidatesMs, networkMs, parseMs, chunkReadMs, normalizeMs },
|
|
readStartedAt,
|
|
),
|
|
};
|
|
} catch (error) {
|
|
console.warn("[ST-BME] 解析远端同步文件失败:", error);
|
|
return {
|
|
exists: false,
|
|
status: "invalid-json",
|
|
filename,
|
|
snapshot: null,
|
|
error,
|
|
timings: finalizeSyncTimings(
|
|
{ resolveCandidatesMs, networkMs, parseMs, chunkReadMs, normalizeMs },
|
|
readStartedAt,
|
|
),
|
|
};
|
|
}
|
|
}
|
|
|
|
return {
|
|
exists: false,
|
|
status: "not-found",
|
|
filename: lastNotFoundFilename,
|
|
snapshot: null,
|
|
timings: finalizeSyncTimings(
|
|
{ resolveCandidatesMs, networkMs, parseMs, chunkReadMs, normalizeMs },
|
|
readStartedAt,
|
|
),
|
|
};
|
|
}
|
|
|
|
async function readRemoteJsonFile(filename, options = {}) {
|
|
const fetchImpl = getFetch(options);
|
|
const response = await fetchImpl(
|
|
`/user/files/${encodeURIComponent(filename)}?t=${Date.now()}`,
|
|
{
|
|
method: "GET",
|
|
cache: "no-store",
|
|
},
|
|
);
|
|
if (response.status === 404) {
|
|
throw new Error("remote-chunk-not-found");
|
|
}
|
|
if (!response.ok) {
|
|
const errorText = await response.text().catch(() => response.statusText);
|
|
throw new Error(errorText || `HTTP ${response.status}`);
|
|
}
|
|
return await response.json();
|
|
}
|
|
|
|
async function readRemoteSnapshotV2Manifest(manifest = {}, chatId = "", options = {}) {
|
|
const readStartedAt = readSyncTimingNow();
|
|
const normalizedChatId = normalizeChatId(chatId);
|
|
const chunks = Array.isArray(manifest?.chunks) ? manifest.chunks : [];
|
|
const nodes = [];
|
|
const edges = [];
|
|
const tombstones = [];
|
|
let runtimeMeta = {};
|
|
let chunkReadMs = 0;
|
|
|
|
for (const chunk of chunks) {
|
|
const filename = String(chunk?.filename || "").trim();
|
|
if (!filename) continue;
|
|
const chunkStartedAt = readSyncTimingNow();
|
|
const payload = await readRemoteJsonFile(filename, options);
|
|
chunkReadMs += readSyncTimingNow() - chunkStartedAt;
|
|
const records = Array.isArray(payload?.records) ? payload.records : [];
|
|
switch (String(chunk.kind || "").trim()) {
|
|
case "nodes":
|
|
nodes.push(...sanitizeSnapshotRecordArray(records));
|
|
break;
|
|
case "edges":
|
|
edges.push(...sanitizeSnapshotRecordArray(records));
|
|
break;
|
|
case "tombstones":
|
|
tombstones.push(...sanitizeSnapshotRecordArray(records));
|
|
break;
|
|
case "runtime-meta":
|
|
runtimeMeta =
|
|
records[0] && typeof records[0] === "object" && !Array.isArray(records[0])
|
|
? toSerializableData(records[0], {})
|
|
: {};
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
const normalizeStartedAt = readSyncTimingNow();
|
|
const snapshot = normalizeSyncSnapshot(
|
|
{
|
|
meta: {
|
|
...runtimeMeta,
|
|
...(manifest?.meta || {}),
|
|
formatVersion: BME_REMOTE_SYNC_FORMAT_VERSION_V2,
|
|
},
|
|
nodes,
|
|
edges,
|
|
tombstones,
|
|
state: toSerializableData(manifest?.state, {
|
|
lastProcessedFloor: -1,
|
|
extractionCount: 0,
|
|
}),
|
|
},
|
|
normalizedChatId,
|
|
);
|
|
const normalizeMs = readSyncTimingNow() - normalizeStartedAt;
|
|
return {
|
|
snapshot,
|
|
timings: finalizeSyncTimings(
|
|
{
|
|
chunkReadMs,
|
|
normalizeMs,
|
|
},
|
|
readStartedAt,
|
|
),
|
|
};
|
|
}
|
|
|
|
async function writeSnapshotToRemote(snapshot, chatId, options = {}) {
|
|
const writeStartedAt = readSyncTimingNow();
|
|
const normalizedChatId = normalizeChatId(chatId);
|
|
const normalizedSnapshot = normalizeSyncSnapshot(snapshot, normalizedChatId);
|
|
const filename = await resolveSyncFilename(normalizedChatId, options);
|
|
const fetchImpl = getFetch(options);
|
|
const envelopeBuildStartedAt = readSyncTimingNow();
|
|
const syncEnvelope = buildRemoteSyncEnvelopeV2(
|
|
normalizedSnapshot,
|
|
normalizedChatId,
|
|
filename,
|
|
);
|
|
const envelopeBuildMs = readSyncTimingNow() - envelopeBuildStartedAt;
|
|
const requestHeaders = {
|
|
...getRequestHeadersSafe(options),
|
|
"Content-Type": "application/json",
|
|
};
|
|
let chunkSerializeMs = 0;
|
|
let chunkUploadMs = 0;
|
|
for (const chunk of syncEnvelope.chunks) {
|
|
const serializeStartedAt = readSyncTimingNow();
|
|
const chunkPayload = JSON.stringify(chunk.payload, null, 2);
|
|
chunkSerializeMs += readSyncTimingNow() - serializeStartedAt;
|
|
const uploadStartedAt = readSyncTimingNow();
|
|
const chunkResponse = await fetchImpl("/api/files/upload", {
|
|
method: "POST",
|
|
headers: requestHeaders,
|
|
body: JSON.stringify({
|
|
name: chunk.filename,
|
|
data: encodeBase64Utf8(chunkPayload),
|
|
}),
|
|
});
|
|
chunkUploadMs += readSyncTimingNow() - uploadStartedAt;
|
|
if (!chunkResponse.ok) {
|
|
const errorText = await chunkResponse.text().catch(() => chunkResponse.statusText);
|
|
throw new Error(errorText || `HTTP ${chunkResponse.status}`);
|
|
}
|
|
}
|
|
const manifestSerializeStartedAt = readSyncTimingNow();
|
|
const manifestPayload = JSON.stringify(syncEnvelope.manifest, null, 2);
|
|
const manifestSerializeMs = readSyncTimingNow() - manifestSerializeStartedAt;
|
|
const manifestUploadStartedAt = readSyncTimingNow();
|
|
const response = await fetchImpl("/api/files/upload", {
|
|
method: "POST",
|
|
headers: requestHeaders,
|
|
body: JSON.stringify({
|
|
name: filename,
|
|
data: encodeBase64Utf8(manifestPayload),
|
|
}),
|
|
});
|
|
const manifestUploadMs = readSyncTimingNow() - manifestUploadStartedAt;
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text().catch(() => response.statusText);
|
|
throw new Error(errorText || `HTTP ${response.status}`);
|
|
}
|
|
|
|
const responseParseStartedAt = readSyncTimingNow();
|
|
const uploadResult = await response.json().catch(() => ({}));
|
|
const responseParseMs = readSyncTimingNow() - responseParseStartedAt;
|
|
return {
|
|
filename,
|
|
path: String(uploadResult?.path || ""),
|
|
payload: syncEnvelope.manifest,
|
|
timings: finalizeSyncTimings(
|
|
{
|
|
envelopeBuildMs,
|
|
chunkSerializeMs,
|
|
chunkUploadMs,
|
|
manifestSerializeMs,
|
|
manifestUploadMs,
|
|
responseParseMs,
|
|
},
|
|
writeStartedAt,
|
|
),
|
|
};
|
|
}
|
|
|
|
function withChatSyncLock(chatId, task) {
|
|
const normalizedChatId = normalizeChatId(chatId);
|
|
if (!normalizedChatId) {
|
|
return Promise.resolve({
|
|
synced: false,
|
|
reason: "missing-chat-id",
|
|
chatId: "",
|
|
});
|
|
}
|
|
|
|
if (syncInFlightByChatId.has(normalizedChatId)) {
|
|
return syncInFlightByChatId.get(normalizedChatId);
|
|
}
|
|
|
|
const taskPromise = Promise.resolve()
|
|
.then(task)
|
|
.catch((error) => {
|
|
console.warn("[ST-BME] 同步任务失败:", error);
|
|
return {
|
|
synced: false,
|
|
chatId: normalizedChatId,
|
|
reason: "sync-error",
|
|
error,
|
|
};
|
|
})
|
|
.finally(() => {
|
|
if (syncInFlightByChatId.get(normalizedChatId) === taskPromise) {
|
|
syncInFlightByChatId.delete(normalizedChatId);
|
|
}
|
|
});
|
|
|
|
syncInFlightByChatId.set(normalizedChatId, taskPromise);
|
|
return taskPromise;
|
|
}
|
|
|
|
export function getOrCreateDeviceId() {
|
|
const storage = getStorage();
|
|
const existingDeviceId = String(storage?.getItem(BME_SYNC_DEVICE_ID_KEY) || "").trim();
|
|
if (existingDeviceId) return existingDeviceId;
|
|
|
|
const deviceId =
|
|
typeof globalThis.crypto?.randomUUID === "function"
|
|
? globalThis.crypto.randomUUID()
|
|
: createFallbackDeviceId();
|
|
|
|
try {
|
|
storage?.setItem(BME_SYNC_DEVICE_ID_KEY, deviceId);
|
|
} catch (error) {
|
|
console.warn("[ST-BME] 写入 deviceId 到 localStorage 失败:", error);
|
|
}
|
|
|
|
return deviceId;
|
|
}
|
|
|
|
export async function getRemoteStatus(chatId, options = {}) {
|
|
const normalizedChatId = normalizeChatId(chatId);
|
|
if (!normalizedChatId) {
|
|
return {
|
|
chatId: "",
|
|
exists: false,
|
|
revision: 0,
|
|
lastModified: 0,
|
|
deviceId: "",
|
|
filename: "",
|
|
status: "missing-chat-id",
|
|
};
|
|
}
|
|
|
|
const remoteResult = await readRemoteSnapshot(normalizedChatId, options);
|
|
if (!remoteResult.exists || !remoteResult.snapshot) {
|
|
if (remoteResult.status !== "not-found" && remoteResult.status !== "missing-chat-id") {
|
|
console.warn("[ST-BME] 远端同步状态读取异常,已回退为可恢复状态:", {
|
|
chatId: normalizedChatId,
|
|
status: remoteResult.status,
|
|
});
|
|
}
|
|
return {
|
|
chatId: normalizedChatId,
|
|
exists: false,
|
|
revision: 0,
|
|
lastModified: 0,
|
|
deviceId: "",
|
|
filename: remoteResult.filename || "",
|
|
status: remoteResult.status,
|
|
error: remoteResult.error || null,
|
|
};
|
|
}
|
|
|
|
return {
|
|
chatId: normalizedChatId,
|
|
exists: true,
|
|
revision: normalizeRevision(remoteResult.snapshot.meta?.revision),
|
|
lastModified: normalizeTimestamp(remoteResult.snapshot.meta?.lastModified, 0),
|
|
deviceId: String(remoteResult.snapshot.meta?.deviceId || "").trim(),
|
|
filename: remoteResult.filename,
|
|
status: "ok",
|
|
};
|
|
}
|
|
|
|
export async function listServerBackups(options = {}) {
|
|
const entries = await fetchBackupManifest(options);
|
|
return {
|
|
entries,
|
|
filename: BME_BACKUP_MANIFEST_FILENAME,
|
|
};
|
|
}
|
|
|
|
export async function backupToServer(chatId, options = {}) {
|
|
const normalizedChatId = normalizeChatId(chatId);
|
|
if (!normalizedChatId) {
|
|
return {
|
|
backedUp: false,
|
|
chatId: "",
|
|
reason: "missing-chat-id",
|
|
timings: finalizeSyncTimings({}, readSyncTimingNow()),
|
|
};
|
|
}
|
|
|
|
const backupStartedAt = readSyncTimingNow();
|
|
try {
|
|
const db = await getDb(normalizedChatId, options);
|
|
const exportStartedAt = readSyncTimingNow();
|
|
const snapshot = normalizeSyncSnapshot(
|
|
await db.exportSnapshot(),
|
|
normalizedChatId,
|
|
);
|
|
const exportMs = readSyncTimingNow() - exportStartedAt;
|
|
const nowMs = Date.now();
|
|
const deviceId = getOrCreateDeviceId();
|
|
|
|
snapshot.meta.chatId = normalizedChatId;
|
|
snapshot.meta.deviceId = snapshot.meta.deviceId || deviceId;
|
|
snapshot.meta.lastModified = normalizeTimestamp(
|
|
snapshot.meta.lastModified,
|
|
nowMs,
|
|
);
|
|
|
|
const envelopeBuildStartedAt = readSyncTimingNow();
|
|
const backupSnapshot = buildManualBackupSnapshot(snapshot, normalizedChatId);
|
|
const envelope = {
|
|
kind: "st-bme-backup",
|
|
version: BME_BACKUP_SCHEMA_VERSION,
|
|
chatId: normalizedChatId,
|
|
createdAt: nowMs,
|
|
sourceDeviceId: deviceId,
|
|
snapshot: backupSnapshot,
|
|
};
|
|
const envelopeBuildMs = readSyncTimingNow() - envelopeBuildStartedAt;
|
|
|
|
const uploadResult = await writeBackupEnvelope(
|
|
envelope,
|
|
normalizedChatId,
|
|
options,
|
|
);
|
|
const uploadTimings = uploadResult?.timings || {};
|
|
const serializedEnvelope = JSON.stringify(envelope);
|
|
|
|
try {
|
|
const manifestWriteStartedAt = readSyncTimingNow();
|
|
await upsertBackupManifestEntry(
|
|
{
|
|
filename: uploadResult.filename,
|
|
serverPath: String(uploadResult.path || "").replace(/^\/+/, ""),
|
|
chatId: normalizedChatId,
|
|
revision: normalizeRevision(snapshot.meta.revision),
|
|
lastModified: normalizeTimestamp(snapshot.meta.lastModified, nowMs),
|
|
backupTime: nowMs,
|
|
size: serializedEnvelope.length,
|
|
schemaVersion: BME_BACKUP_SCHEMA_VERSION,
|
|
},
|
|
options,
|
|
);
|
|
const manifestWriteMs = readSyncTimingNow() - manifestWriteStartedAt;
|
|
const metaPatchStartedAt = readSyncTimingNow();
|
|
await patchDbMeta(db, {
|
|
deviceId,
|
|
syncDirty: false,
|
|
syncDirtyReason: "",
|
|
lastBackupUploadedAt: nowMs,
|
|
lastBackupFilename: uploadResult.filename,
|
|
});
|
|
const metaPatchMs = readSyncTimingNow() - metaPatchStartedAt;
|
|
|
|
return {
|
|
backedUp: true,
|
|
chatId: normalizedChatId,
|
|
filename: uploadResult.filename,
|
|
remotePath: uploadResult.path,
|
|
revision: normalizeRevision(snapshot.meta.revision),
|
|
backupTime: nowMs,
|
|
timings: finalizeSyncTimings(
|
|
{
|
|
exportMs,
|
|
envelopeBuildMs,
|
|
uploadMs: Number(uploadTimings.totalMs || 0),
|
|
envelopeSerializeMs: Number(uploadTimings.serializeMs || 0),
|
|
envelopeResponseParseMs: Number(uploadTimings.responseParseMs || 0),
|
|
manifestWriteMs,
|
|
metaPatchMs,
|
|
},
|
|
backupStartedAt,
|
|
),
|
|
};
|
|
} catch (manifestError) {
|
|
return {
|
|
backedUp: false,
|
|
chatId: normalizedChatId,
|
|
filename: uploadResult.filename,
|
|
remotePath: uploadResult.path,
|
|
reason: "backup-manifest-error",
|
|
backupUploaded: true,
|
|
error: manifestError,
|
|
timings: finalizeSyncTimings(
|
|
{
|
|
exportMs,
|
|
envelopeBuildMs,
|
|
uploadMs: Number(uploadTimings.totalMs || 0),
|
|
envelopeSerializeMs: Number(uploadTimings.serializeMs || 0),
|
|
envelopeResponseParseMs: Number(uploadTimings.responseParseMs || 0),
|
|
},
|
|
backupStartedAt,
|
|
),
|
|
};
|
|
}
|
|
} catch (error) {
|
|
console.warn("[ST-BME] 手动备份到云端失败:", error);
|
|
return {
|
|
backedUp: false,
|
|
chatId: normalizedChatId,
|
|
reason: "backup-error",
|
|
error,
|
|
timings: finalizeSyncTimings({}, backupStartedAt),
|
|
};
|
|
}
|
|
}
|
|
|
|
export async function restoreFromServer(chatId, options = {}) {
|
|
const normalizedChatId = normalizeChatId(chatId);
|
|
if (!normalizedChatId) {
|
|
return {
|
|
restored: false,
|
|
chatId: "",
|
|
reason: "missing-chat-id",
|
|
timings: finalizeSyncTimings({}, readSyncTimingNow()),
|
|
};
|
|
}
|
|
|
|
const restoreStartedAt = readSyncTimingNow();
|
|
try {
|
|
const db = await getDb(normalizedChatId, options);
|
|
const remoteResult = await readBackupEnvelope(normalizedChatId, options);
|
|
const downloadTimings = remoteResult?.timings || {};
|
|
if (!remoteResult.exists || !remoteResult.envelope) {
|
|
return {
|
|
restored: false,
|
|
chatId: normalizedChatId,
|
|
filename: remoteResult.filename || "",
|
|
reason: remoteResult.reason || "backup-missing",
|
|
timings: finalizeSyncTimings(
|
|
{
|
|
downloadMs: Number(downloadTimings.totalMs || 0),
|
|
lookupMs: Number(downloadTimings.lookupMs || 0),
|
|
networkMs: Number(downloadTimings.networkMs || 0),
|
|
envelopeParseMs: Number(downloadTimings.parseMs || 0),
|
|
},
|
|
restoreStartedAt,
|
|
),
|
|
};
|
|
}
|
|
|
|
const envelope = remoteResult.envelope;
|
|
if (envelope.version !== BME_BACKUP_SCHEMA_VERSION) {
|
|
return {
|
|
restored: false,
|
|
chatId: normalizedChatId,
|
|
filename: remoteResult.filename,
|
|
reason: "backup-version-mismatch",
|
|
timings: finalizeSyncTimings(
|
|
{
|
|
downloadMs: Number(downloadTimings.totalMs || 0),
|
|
lookupMs: Number(downloadTimings.lookupMs || 0),
|
|
networkMs: Number(downloadTimings.networkMs || 0),
|
|
envelopeParseMs: Number(downloadTimings.parseMs || 0),
|
|
},
|
|
restoreStartedAt,
|
|
),
|
|
};
|
|
}
|
|
|
|
if (envelope.chatId !== normalizedChatId) {
|
|
return {
|
|
restored: false,
|
|
chatId: normalizedChatId,
|
|
filename: remoteResult.filename,
|
|
reason: "backup-chat-id-mismatch",
|
|
timings: finalizeSyncTimings(
|
|
{
|
|
downloadMs: Number(downloadTimings.totalMs || 0),
|
|
lookupMs: Number(downloadTimings.lookupMs || 0),
|
|
networkMs: Number(downloadTimings.networkMs || 0),
|
|
envelopeParseMs: Number(downloadTimings.parseMs || 0),
|
|
},
|
|
restoreStartedAt,
|
|
),
|
|
};
|
|
}
|
|
|
|
const snapshot = markBackendVectorSnapshotDirty(
|
|
markManualBackupHistoryForLocalRebind(
|
|
envelope.snapshot,
|
|
normalizedChatId,
|
|
),
|
|
"backend-backup-restore-unverified",
|
|
"后端向量索引已从云备份恢复,需要在当前环境重建",
|
|
);
|
|
if (normalizeChatId(snapshot.meta?.chatId) !== normalizedChatId) {
|
|
return {
|
|
restored: false,
|
|
chatId: normalizedChatId,
|
|
filename: remoteResult.filename,
|
|
reason: "snapshot-chat-id-mismatch",
|
|
timings: finalizeSyncTimings(
|
|
{
|
|
downloadMs: Number(downloadTimings.totalMs || 0),
|
|
lookupMs: Number(downloadTimings.lookupMs || 0),
|
|
networkMs: Number(downloadTimings.networkMs || 0),
|
|
envelopeParseMs: Number(downloadTimings.parseMs || 0),
|
|
},
|
|
restoreStartedAt,
|
|
),
|
|
};
|
|
}
|
|
|
|
const localExportStartedAt = readSyncTimingNow();
|
|
const localSnapshot = normalizeSyncSnapshot(
|
|
await db.exportSnapshot(),
|
|
normalizedChatId,
|
|
);
|
|
const localExportMs = readSyncTimingNow() - localExportStartedAt;
|
|
const safetySnapshotStartedAt = readSyncTimingNow();
|
|
await createRestoreSafetySnapshot(
|
|
normalizedChatId,
|
|
localSnapshot,
|
|
options,
|
|
);
|
|
const safetySnapshotMs = readSyncTimingNow() - safetySnapshotStartedAt;
|
|
|
|
const importStartedAt = readSyncTimingNow();
|
|
await db.importSnapshot(snapshot, {
|
|
mode: "replace",
|
|
preserveRevision: true,
|
|
revision: normalizeRevision(snapshot.meta.revision),
|
|
markSyncDirty: false,
|
|
});
|
|
const importMs = readSyncTimingNow() - importStartedAt;
|
|
|
|
const metaPatchStartedAt = readSyncTimingNow();
|
|
await patchDbMeta(db, {
|
|
deviceId: getOrCreateDeviceId(),
|
|
syncDirty: false,
|
|
syncDirtyReason: "",
|
|
lastBackupRestoredAt: Date.now(),
|
|
lastBackupFilename:
|
|
remoteResult.filename || buildBackupFilename(normalizedChatId),
|
|
});
|
|
const metaPatchMs = readSyncTimingNow() - metaPatchStartedAt;
|
|
|
|
const hookStartedAt = readSyncTimingNow();
|
|
await invokeSyncAppliedHook(options, {
|
|
chatId: normalizedChatId,
|
|
action: "restore-backup",
|
|
revision: normalizeRevision(snapshot.meta.revision),
|
|
});
|
|
const hookMs = readSyncTimingNow() - hookStartedAt;
|
|
|
|
return {
|
|
restored: true,
|
|
chatId: normalizedChatId,
|
|
filename: remoteResult.filename,
|
|
revision: normalizeRevision(snapshot.meta.revision),
|
|
backupTime: normalizeTimestamp(envelope.createdAt, 0),
|
|
timings: finalizeSyncTimings(
|
|
{
|
|
downloadMs: Number(downloadTimings.totalMs || 0),
|
|
lookupMs: Number(downloadTimings.lookupMs || 0),
|
|
networkMs: Number(downloadTimings.networkMs || 0),
|
|
envelopeParseMs: Number(downloadTimings.parseMs || 0),
|
|
localExportMs,
|
|
safetySnapshotMs,
|
|
importMs,
|
|
metaPatchMs,
|
|
hookMs,
|
|
},
|
|
restoreStartedAt,
|
|
),
|
|
};
|
|
} catch (error) {
|
|
console.warn("[ST-BME] 从云端恢复备份失败:", error);
|
|
return {
|
|
restored: false,
|
|
chatId: normalizedChatId,
|
|
reason: "restore-error",
|
|
error,
|
|
timings: finalizeSyncTimings({}, restoreStartedAt),
|
|
};
|
|
}
|
|
}
|
|
|
|
export async function deleteServerBackup(chatId, options = {}) {
|
|
const normalizedChatId = normalizeChatId(chatId);
|
|
if (!normalizedChatId) {
|
|
return {
|
|
deleted: false,
|
|
chatId: "",
|
|
reason: "missing-chat-id",
|
|
};
|
|
}
|
|
|
|
const lookup = await resolveBackupLookupContext(normalizedChatId, options);
|
|
const targetCandidate = lookup.candidates[0] || {
|
|
filename: buildBackupFilename(normalizedChatId),
|
|
serverPath: normalizeSelectedBackupServerPath(
|
|
"",
|
|
buildBackupFilename(normalizedChatId),
|
|
),
|
|
};
|
|
const filename = targetCandidate.filename;
|
|
const serverPath =
|
|
targetCandidate.serverPath ||
|
|
normalizeSelectedBackupServerPath("", filename);
|
|
const fetchImpl = getFetch(options);
|
|
|
|
try {
|
|
const response = await fetchImpl("/api/files/delete", {
|
|
method: "POST",
|
|
headers: {
|
|
...getRequestHeadersSafe(options),
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
path: serverPath,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok && response.status !== 404) {
|
|
const errorText = await response.text().catch(() => response.statusText);
|
|
throw new Error(errorText || `HTTP ${response.status}`);
|
|
}
|
|
|
|
try {
|
|
const existingEntries =
|
|
lookup.manifestError == null
|
|
? lookup.manifestEntries
|
|
: await fetchBackupManifest(options);
|
|
const filteredEntries = existingEntries.filter(
|
|
(entry) => entry.filename !== filename,
|
|
);
|
|
await writeBackupManifest(filteredEntries, options);
|
|
|
|
const remainingEntry =
|
|
sortBackupManifestEntries(
|
|
filteredEntries.filter(
|
|
(entry) => normalizeChatId(entry.chatId) === normalizedChatId,
|
|
),
|
|
)[0] || null;
|
|
const localMetaUpdated = await syncDeletedBackupMeta(
|
|
normalizedChatId,
|
|
remainingEntry,
|
|
options,
|
|
);
|
|
|
|
return {
|
|
deleted: true,
|
|
chatId: normalizedChatId,
|
|
filename,
|
|
localMetaUpdated,
|
|
};
|
|
} catch (manifestError) {
|
|
return {
|
|
deleted: false,
|
|
chatId: normalizedChatId,
|
|
filename,
|
|
reason: "delete-backup-manifest-error",
|
|
backupDeleted: true,
|
|
error: manifestError,
|
|
};
|
|
}
|
|
} catch (error) {
|
|
console.warn("[ST-BME] 删除服务端备份失败:", error);
|
|
return {
|
|
deleted: false,
|
|
chatId: normalizedChatId,
|
|
filename,
|
|
reason: "delete-backup-error",
|
|
error,
|
|
};
|
|
}
|
|
}
|
|
|
|
export async function upload(chatId, options = {}) {
|
|
const normalizedChatId = normalizeChatId(chatId);
|
|
if (!normalizedChatId) {
|
|
return {
|
|
uploaded: false,
|
|
chatId: "",
|
|
reason: "missing-chat-id",
|
|
timings: finalizeSyncTimings({}, readSyncTimingNow()),
|
|
};
|
|
}
|
|
|
|
const uploadStartedAt = readSyncTimingNow();
|
|
try {
|
|
const db = await getDb(normalizedChatId, options);
|
|
const exportStartedAt = readSyncTimingNow();
|
|
const localSnapshot = normalizeSyncSnapshot(await db.exportSnapshot(), normalizedChatId);
|
|
const exportMs = readSyncTimingNow() - exportStartedAt;
|
|
const nowMs = Date.now();
|
|
|
|
const deviceId = getOrCreateDeviceId();
|
|
localSnapshot.meta.deviceId = localSnapshot.meta.deviceId || deviceId;
|
|
localSnapshot.meta.chatId = normalizedChatId;
|
|
localSnapshot.meta.lastModified = normalizeTimestamp(localSnapshot.meta.lastModified, nowMs);
|
|
|
|
const uploadResult = await writeSnapshotToRemote(localSnapshot, normalizedChatId, options);
|
|
const uploadTimings = uploadResult?.timings || {};
|
|
|
|
const metaPatchStartedAt = readSyncTimingNow();
|
|
await patchDbMeta(db, {
|
|
deviceId,
|
|
lastSyncUploadedAt: nowMs,
|
|
lastSyncedRevision: normalizeRevision(localSnapshot.meta.revision),
|
|
syncDirty: false,
|
|
syncDirtyReason: "",
|
|
lastModified: localSnapshot.meta.lastModified,
|
|
remoteSyncFormatVersion: BME_REMOTE_SYNC_FORMAT_VERSION_V2,
|
|
});
|
|
const metaPatchMs = readSyncTimingNow() - metaPatchStartedAt;
|
|
|
|
return {
|
|
uploaded: true,
|
|
chatId: normalizedChatId,
|
|
filename: uploadResult.filename,
|
|
remotePath: uploadResult.path,
|
|
revision: normalizeRevision(localSnapshot.meta.revision),
|
|
timings: finalizeSyncTimings(
|
|
{
|
|
exportMs,
|
|
envelopeBuildMs: Number(uploadTimings.envelopeBuildMs || 0),
|
|
chunkSerializeMs: Number(uploadTimings.chunkSerializeMs || 0),
|
|
chunkUploadMs: Number(uploadTimings.chunkUploadMs || 0),
|
|
manifestSerializeMs: Number(uploadTimings.manifestSerializeMs || 0),
|
|
manifestUploadMs: Number(uploadTimings.manifestUploadMs || 0),
|
|
responseParseMs: Number(uploadTimings.responseParseMs || 0),
|
|
metaPatchMs,
|
|
},
|
|
uploadStartedAt,
|
|
),
|
|
};
|
|
} catch (error) {
|
|
console.warn("[ST-BME] 上传同步文件失败:", error);
|
|
return {
|
|
uploaded: false,
|
|
chatId: normalizedChatId,
|
|
reason: "upload-error",
|
|
error,
|
|
timings: finalizeSyncTimings({}, uploadStartedAt),
|
|
};
|
|
}
|
|
}
|
|
|
|
export async function download(chatId, options = {}) {
|
|
const normalizedChatId = normalizeChatId(chatId);
|
|
if (!normalizedChatId) {
|
|
return {
|
|
downloaded: false,
|
|
exists: false,
|
|
chatId: "",
|
|
reason: "missing-chat-id",
|
|
timings: finalizeSyncTimings({}, readSyncTimingNow()),
|
|
};
|
|
}
|
|
|
|
const downloadStartedAt = readSyncTimingNow();
|
|
try {
|
|
const db = await getDb(normalizedChatId, options);
|
|
const remoteResult = await readRemoteSnapshot(normalizedChatId, options);
|
|
const remoteTimings = remoteResult?.timings || {};
|
|
|
|
if (!remoteResult.exists || !remoteResult.snapshot) {
|
|
return {
|
|
downloaded: false,
|
|
exists: false,
|
|
chatId: normalizedChatId,
|
|
filename: remoteResult.filename || "",
|
|
reason: remoteResult.status || "remote-missing",
|
|
timings: finalizeSyncTimings(
|
|
{
|
|
resolveCandidatesMs: Number(remoteTimings.resolveCandidatesMs || 0),
|
|
networkMs: Number(remoteTimings.networkMs || 0),
|
|
parseMs: Number(remoteTimings.parseMs || 0),
|
|
chunkReadMs: Number(remoteTimings.chunkReadMs || 0),
|
|
normalizeMs: Number(remoteTimings.normalizeMs || 0),
|
|
},
|
|
downloadStartedAt,
|
|
),
|
|
};
|
|
}
|
|
|
|
const remoteSnapshot = markBackendVectorSnapshotDirty(
|
|
normalizeSyncSnapshot(remoteResult.snapshot, normalizedChatId),
|
|
"backend-sync-download-unverified",
|
|
"后端向量索引已从远端同步恢复,需要在当前环境重建",
|
|
);
|
|
const remoteRevision = normalizeRevision(remoteSnapshot.meta.revision);
|
|
|
|
const importStartedAt = readSyncTimingNow();
|
|
await db.importSnapshot(remoteSnapshot, {
|
|
mode: "replace",
|
|
preserveRevision: true,
|
|
revision: remoteRevision,
|
|
markSyncDirty: false,
|
|
});
|
|
const importMs = readSyncTimingNow() - importStartedAt;
|
|
|
|
const metaPatchStartedAt = readSyncTimingNow();
|
|
await patchDbMeta(db, {
|
|
deviceId: getOrCreateDeviceId(),
|
|
lastSyncDownloadedAt: Date.now(),
|
|
lastSyncedRevision: remoteRevision,
|
|
syncDirty: false,
|
|
syncDirtyReason: "",
|
|
remoteSyncFormatVersion: BME_REMOTE_SYNC_FORMAT_VERSION_V2,
|
|
});
|
|
const metaPatchMs = readSyncTimingNow() - metaPatchStartedAt;
|
|
|
|
const hookStartedAt = readSyncTimingNow();
|
|
await invokeSyncAppliedHook(options, {
|
|
chatId: normalizedChatId,
|
|
action: "download",
|
|
revision: remoteRevision,
|
|
});
|
|
const hookMs = readSyncTimingNow() - hookStartedAt;
|
|
|
|
return {
|
|
downloaded: true,
|
|
exists: true,
|
|
chatId: normalizedChatId,
|
|
filename: remoteResult.filename,
|
|
revision: remoteRevision,
|
|
timings: finalizeSyncTimings(
|
|
{
|
|
resolveCandidatesMs: Number(remoteTimings.resolveCandidatesMs || 0),
|
|
networkMs: Number(remoteTimings.networkMs || 0),
|
|
parseMs: Number(remoteTimings.parseMs || 0),
|
|
chunkReadMs: Number(remoteTimings.chunkReadMs || 0),
|
|
normalizeMs: Number(remoteTimings.normalizeMs || 0),
|
|
importMs,
|
|
metaPatchMs,
|
|
hookMs,
|
|
},
|
|
downloadStartedAt,
|
|
),
|
|
};
|
|
} catch (error) {
|
|
console.warn("[ST-BME] 下载同步文件失败:", error);
|
|
return {
|
|
downloaded: false,
|
|
exists: false,
|
|
chatId: normalizedChatId,
|
|
reason: "download-error",
|
|
error,
|
|
timings: finalizeSyncTimings({}, downloadStartedAt),
|
|
};
|
|
}
|
|
}
|
|
|
|
export function mergeSnapshots(localSnapshot, remoteSnapshot, options = {}) {
|
|
const normalizedChatId = normalizeChatId(options.chatId || localSnapshot?.meta?.chatId || remoteSnapshot?.meta?.chatId);
|
|
const local = normalizeSyncSnapshot(localSnapshot, normalizedChatId);
|
|
const remote = normalizeSyncSnapshot(remoteSnapshot, normalizedChatId);
|
|
|
|
const mergedTombstoneIndex = buildTombstoneIndex([
|
|
...local.tombstones,
|
|
...remote.tombstones,
|
|
]);
|
|
const mergedTombstones = Array.from(mergedTombstoneIndex.byId.values());
|
|
|
|
const mergedNodes = filterRecordsByTombstones(
|
|
mergeRecordCollectionById(local.nodes, remote.nodes),
|
|
"node",
|
|
mergedTombstoneIndex,
|
|
);
|
|
const mergedEdges = filterRecordsByTombstones(
|
|
mergeRecordCollectionById(local.edges, remote.edges),
|
|
"edge",
|
|
mergedTombstoneIndex,
|
|
);
|
|
|
|
const localRevision = normalizeRevision(local.meta.revision);
|
|
const remoteRevision = normalizeRevision(remote.meta.revision);
|
|
const mergedRevision = Math.max(localRevision, remoteRevision) + 1;
|
|
|
|
const baseMergedState = {
|
|
lastProcessedFloor: Math.max(
|
|
Number(local.state?.lastProcessedFloor ?? -1),
|
|
Number(remote.state?.lastProcessedFloor ?? -1),
|
|
),
|
|
extractionCount: Math.max(
|
|
Number(local.state?.extractionCount ?? 0),
|
|
Number(remote.state?.extractionCount ?? 0),
|
|
),
|
|
};
|
|
|
|
const mergedHistoryResult = mergeRuntimeHistoryMeta(
|
|
local.meta?.[RUNTIME_HISTORY_META_KEY],
|
|
remote.meta?.[RUNTIME_HISTORY_META_KEY],
|
|
{
|
|
chatId: normalizedChatId,
|
|
fallbackLastProcessedFloor: baseMergedState.lastProcessedFloor,
|
|
fallbackExtractionCount: baseMergedState.extractionCount,
|
|
},
|
|
);
|
|
|
|
const mergedLastProcessedFloor = Math.min(
|
|
Number(baseMergedState.lastProcessedFloor ?? -1),
|
|
Number(mergedHistoryResult.safeLastProcessedFloor ?? -1),
|
|
);
|
|
|
|
const mergedState = {
|
|
lastProcessedFloor: Number.isFinite(mergedLastProcessedFloor)
|
|
? Math.floor(mergedLastProcessedFloor)
|
|
: -1,
|
|
extractionCount: Math.max(
|
|
Number(baseMergedState.extractionCount ?? 0),
|
|
Number(mergedHistoryResult.history?.extractionCount ?? 0),
|
|
),
|
|
};
|
|
|
|
const mergedHistoryState = {
|
|
...mergedHistoryResult.history,
|
|
chatId: normalizedChatId,
|
|
lastProcessedAssistantFloor: mergedState.lastProcessedFloor,
|
|
extractionCount: mergedState.extractionCount,
|
|
processedMessageHashes: sortProcessedMessageHashes(
|
|
Object.fromEntries(
|
|
Object.entries(mergedHistoryResult.history?.processedMessageHashes || {}).filter(
|
|
([floorKey]) => {
|
|
const floor = Number.parseInt(floorKey, 10);
|
|
return Number.isFinite(floor) && floor >= 0 && floor <= mergedState.lastProcessedFloor;
|
|
},
|
|
),
|
|
),
|
|
),
|
|
};
|
|
|
|
const mergedVectorState = mergeRuntimeVectorMeta(
|
|
local.meta?.[RUNTIME_VECTOR_META_KEY],
|
|
remote.meta?.[RUNTIME_VECTOR_META_KEY],
|
|
{
|
|
mergedNodes,
|
|
fallbackLastProcessedFloor: mergedState.lastProcessedFloor,
|
|
},
|
|
);
|
|
|
|
const mergedBatchJournal = mergeRuntimeBatchJournal(
|
|
local.meta?.[RUNTIME_BATCH_JOURNAL_META_KEY],
|
|
remote.meta?.[RUNTIME_BATCH_JOURNAL_META_KEY],
|
|
{
|
|
maxTrustedFloor: mergedState.lastProcessedFloor,
|
|
},
|
|
);
|
|
|
|
const mergedLastRecallResult = mergeRuntimeLastRecallResult(local, remote);
|
|
const mergedSummaryState =
|
|
chooseNewerRuntimePayload(
|
|
local.meta?.[RUNTIME_SUMMARY_STATE_META_KEY],
|
|
remote.meta?.[RUNTIME_SUMMARY_STATE_META_KEY],
|
|
) ??
|
|
toSerializableData(
|
|
remote.meta?.[RUNTIME_SUMMARY_STATE_META_KEY] ??
|
|
local.meta?.[RUNTIME_SUMMARY_STATE_META_KEY] ??
|
|
{},
|
|
{},
|
|
);
|
|
const mergedMaintenanceJournal =
|
|
chooseNewerRuntimePayload(
|
|
local.meta?.[RUNTIME_MAINTENANCE_JOURNAL_META_KEY],
|
|
remote.meta?.[RUNTIME_MAINTENANCE_JOURNAL_META_KEY],
|
|
) ??
|
|
toSerializableData(
|
|
remote.meta?.[RUNTIME_MAINTENANCE_JOURNAL_META_KEY] ??
|
|
local.meta?.[RUNTIME_MAINTENANCE_JOURNAL_META_KEY] ??
|
|
[],
|
|
[],
|
|
);
|
|
const mergedKnowledgeState =
|
|
chooseNewerRuntimePayload(
|
|
local.meta?.[RUNTIME_KNOWLEDGE_STATE_META_KEY],
|
|
remote.meta?.[RUNTIME_KNOWLEDGE_STATE_META_KEY],
|
|
) ??
|
|
toSerializableData(
|
|
remote.meta?.[RUNTIME_KNOWLEDGE_STATE_META_KEY] ??
|
|
local.meta?.[RUNTIME_KNOWLEDGE_STATE_META_KEY] ??
|
|
{},
|
|
{},
|
|
);
|
|
const mergedRegionState =
|
|
chooseNewerRuntimePayload(
|
|
local.meta?.[RUNTIME_REGION_STATE_META_KEY],
|
|
remote.meta?.[RUNTIME_REGION_STATE_META_KEY],
|
|
) ??
|
|
toSerializableData(
|
|
remote.meta?.[RUNTIME_REGION_STATE_META_KEY] ??
|
|
local.meta?.[RUNTIME_REGION_STATE_META_KEY] ??
|
|
{},
|
|
{},
|
|
);
|
|
const mergedTimelineState =
|
|
chooseNewerRuntimePayload(
|
|
local.meta?.[RUNTIME_TIMELINE_STATE_META_KEY],
|
|
remote.meta?.[RUNTIME_TIMELINE_STATE_META_KEY],
|
|
) ??
|
|
toSerializableData(
|
|
remote.meta?.[RUNTIME_TIMELINE_STATE_META_KEY] ??
|
|
local.meta?.[RUNTIME_TIMELINE_STATE_META_KEY] ??
|
|
{},
|
|
{},
|
|
);
|
|
|
|
const mergedLastProcessedSeq = Math.max(
|
|
normalizeNonNegativeInteger(local.meta?.[RUNTIME_LAST_PROCESSED_SEQ_META_KEY], 0),
|
|
normalizeNonNegativeInteger(remote.meta?.[RUNTIME_LAST_PROCESSED_SEQ_META_KEY], 0),
|
|
normalizeNonNegativeInteger(mergedState.lastProcessedFloor, 0),
|
|
);
|
|
|
|
const mergedRuntimeGraphVersion = Math.max(
|
|
normalizeNonNegativeInteger(local.meta?.[RUNTIME_GRAPH_VERSION_META_KEY], 0),
|
|
normalizeNonNegativeInteger(remote.meta?.[RUNTIME_GRAPH_VERSION_META_KEY], 0),
|
|
normalizeNonNegativeInteger(mergedRevision, 0),
|
|
);
|
|
|
|
const mergedMeta = {
|
|
...local.meta,
|
|
...remote.meta,
|
|
[RUNTIME_HISTORY_META_KEY]: mergedHistoryState,
|
|
[RUNTIME_VECTOR_META_KEY]: mergedVectorState,
|
|
[RUNTIME_BATCH_JOURNAL_META_KEY]: mergedBatchJournal,
|
|
[RUNTIME_LAST_RECALL_META_KEY]: mergedLastRecallResult,
|
|
[RUNTIME_SUMMARY_STATE_META_KEY]: mergedSummaryState,
|
|
[RUNTIME_MAINTENANCE_JOURNAL_META_KEY]: mergedMaintenanceJournal,
|
|
[RUNTIME_KNOWLEDGE_STATE_META_KEY]: mergedKnowledgeState,
|
|
[RUNTIME_REGION_STATE_META_KEY]: mergedRegionState,
|
|
[RUNTIME_TIMELINE_STATE_META_KEY]: mergedTimelineState,
|
|
[RUNTIME_LAST_PROCESSED_SEQ_META_KEY]: mergedLastProcessedSeq,
|
|
[RUNTIME_GRAPH_VERSION_META_KEY]: mergedRuntimeGraphVersion,
|
|
schemaVersion: Math.max(
|
|
Number(local.meta?.schemaVersion || 1),
|
|
Number(remote.meta?.schemaVersion || 1),
|
|
),
|
|
chatId: normalizedChatId,
|
|
deviceId: String(local.meta?.deviceId || remote.meta?.deviceId || getOrCreateDeviceId()).trim(),
|
|
revision: mergedRevision,
|
|
lastModified: Math.max(
|
|
normalizeTimestamp(local.meta?.lastModified, 0),
|
|
normalizeTimestamp(remote.meta?.lastModified, 0),
|
|
Date.now(),
|
|
),
|
|
nodeCount: mergedNodes.length,
|
|
edgeCount: mergedEdges.length,
|
|
tombstoneCount: mergedTombstones.length,
|
|
syncDirty: false,
|
|
syncDirtyReason: "",
|
|
lastProcessedFloor: mergedState.lastProcessedFloor,
|
|
extractionCount: mergedState.extractionCount,
|
|
};
|
|
|
|
return {
|
|
meta: mergedMeta,
|
|
nodes: mergedNodes,
|
|
edges: mergedEdges,
|
|
tombstones: mergedTombstones,
|
|
state: mergedState,
|
|
};
|
|
}
|
|
|
|
export async function syncNow(chatId, options = {}) {
|
|
const normalizedChatId = normalizeChatId(chatId);
|
|
if (!normalizedChatId) {
|
|
return {
|
|
synced: false,
|
|
chatId: "",
|
|
reason: "missing-chat-id",
|
|
};
|
|
}
|
|
|
|
if (!isAutomaticCloudMode(options)) {
|
|
return {
|
|
synced: false,
|
|
chatId: normalizedChatId,
|
|
action: "manual-probe",
|
|
reason: "manual-cloud-mode",
|
|
remoteStatus: null,
|
|
};
|
|
}
|
|
|
|
return await withChatSyncLock(normalizedChatId, async () => {
|
|
const db = await getDb(normalizedChatId, options);
|
|
const localSnapshot = normalizeSyncSnapshot(await db.exportSnapshot(), normalizedChatId);
|
|
const localRevision = normalizeRevision(localSnapshot.meta.revision);
|
|
const localDirty = Boolean(await db.getMeta("syncDirty", false));
|
|
|
|
const remoteResult = await readRemoteSnapshot(normalizedChatId, options);
|
|
if (!remoteResult.exists || !remoteResult.snapshot) {
|
|
if (remoteResult.status !== "not-found") {
|
|
return {
|
|
synced: false,
|
|
chatId: normalizedChatId,
|
|
reason: remoteResult.status || "remote-read-error",
|
|
error: remoteResult.error || null,
|
|
};
|
|
}
|
|
|
|
const uploadResult = await upload(normalizedChatId, options);
|
|
return {
|
|
synced: Boolean(uploadResult.uploaded),
|
|
chatId: normalizedChatId,
|
|
action: uploadResult.uploaded ? "upload" : "none",
|
|
...uploadResult,
|
|
};
|
|
}
|
|
|
|
const remoteSnapshot = normalizeSyncSnapshot(remoteResult.snapshot, normalizedChatId);
|
|
const remoteRevision = normalizeRevision(remoteSnapshot.meta.revision);
|
|
|
|
if (remoteRevision > localRevision && !localDirty) {
|
|
const downloadResult = await download(normalizedChatId, options);
|
|
return {
|
|
synced: Boolean(downloadResult.downloaded),
|
|
chatId: normalizedChatId,
|
|
action: downloadResult.downloaded ? "download" : "none",
|
|
...downloadResult,
|
|
};
|
|
}
|
|
|
|
if (localRevision > remoteRevision && !options.forceMerge) {
|
|
const uploadResult = await upload(normalizedChatId, options);
|
|
return {
|
|
synced: Boolean(uploadResult.uploaded),
|
|
chatId: normalizedChatId,
|
|
action: uploadResult.uploaded ? "upload" : "none",
|
|
...uploadResult,
|
|
};
|
|
}
|
|
|
|
if (localRevision === remoteRevision && !localDirty && !options.forceMerge) {
|
|
return {
|
|
synced: true,
|
|
chatId: normalizedChatId,
|
|
action: "noop",
|
|
revision: localRevision,
|
|
};
|
|
}
|
|
|
|
const mergedSnapshot = markBackendVectorSnapshotDirty(
|
|
mergeSnapshots(localSnapshot, remoteSnapshot, {
|
|
chatId: normalizedChatId,
|
|
}),
|
|
"backend-sync-merge-unverified",
|
|
"后端向量索引已从远端合并恢复,需要在当前环境重建",
|
|
);
|
|
|
|
await db.importSnapshot(mergedSnapshot, {
|
|
mode: "replace",
|
|
preserveRevision: true,
|
|
revision: mergedSnapshot.meta.revision,
|
|
markSyncDirty: false,
|
|
});
|
|
|
|
await patchDbMeta(db, {
|
|
deviceId: getOrCreateDeviceId(),
|
|
lastSyncDownloadedAt: Date.now(),
|
|
lastSyncedRevision: normalizeRevision(mergedSnapshot.meta.revision),
|
|
syncDirty: false,
|
|
syncDirtyReason: "",
|
|
lastProcessedFloor: mergedSnapshot.state.lastProcessedFloor,
|
|
extractionCount: mergedSnapshot.state.extractionCount,
|
|
remoteSyncFormatVersion: BME_REMOTE_SYNC_FORMAT_VERSION_V2,
|
|
});
|
|
|
|
const uploadResult = await writeSnapshotToRemote(mergedSnapshot, normalizedChatId, options);
|
|
|
|
await patchDbMeta(db, {
|
|
lastSyncUploadedAt: Date.now(),
|
|
lastSyncedRevision: normalizeRevision(mergedSnapshot.meta.revision),
|
|
syncDirty: false,
|
|
syncDirtyReason: "",
|
|
remoteSyncFormatVersion: BME_REMOTE_SYNC_FORMAT_VERSION_V2,
|
|
});
|
|
|
|
await invokeSyncAppliedHook(options, {
|
|
chatId: normalizedChatId,
|
|
action: "merge",
|
|
revision: normalizeRevision(mergedSnapshot.meta.revision),
|
|
});
|
|
|
|
return {
|
|
synced: true,
|
|
chatId: normalizedChatId,
|
|
action: "merge",
|
|
filename: uploadResult.filename,
|
|
remotePath: uploadResult.path,
|
|
revision: normalizeRevision(mergedSnapshot.meta.revision),
|
|
};
|
|
});
|
|
}
|
|
|
|
export function scheduleUpload(chatId, options = {}) {
|
|
const normalizedChatId = normalizeChatId(chatId);
|
|
if (!normalizedChatId) {
|
|
return {
|
|
scheduled: false,
|
|
chatId: "",
|
|
reason: "missing-chat-id",
|
|
};
|
|
}
|
|
|
|
if (!isAutomaticCloudMode(options)) {
|
|
return {
|
|
scheduled: false,
|
|
chatId: normalizedChatId,
|
|
reason: "manual-cloud-mode",
|
|
};
|
|
}
|
|
|
|
const debounceMs = Number.isFinite(Number(options.debounceMs))
|
|
? Math.max(0, Math.floor(Number(options.debounceMs)))
|
|
: BME_SYNC_UPLOAD_DEBOUNCE_MS;
|
|
|
|
const previousTimer = uploadDebounceTimerByChatId.get(normalizedChatId);
|
|
if (previousTimer) {
|
|
clearTimeout(previousTimer);
|
|
}
|
|
|
|
const timer = setTimeout(() => {
|
|
uploadDebounceTimerByChatId.delete(normalizedChatId);
|
|
withChatSyncLock(normalizedChatId, async () => await upload(normalizedChatId, options));
|
|
}, debounceMs);
|
|
|
|
uploadDebounceTimerByChatId.set(normalizedChatId, timer);
|
|
|
|
return {
|
|
scheduled: true,
|
|
chatId: normalizedChatId,
|
|
debounceMs,
|
|
};
|
|
}
|
|
|
|
export function autoSyncOnChatChange(chatId, options = {}) {
|
|
const normalizedChatId = normalizeChatId(chatId);
|
|
if (!normalizedChatId) {
|
|
return Promise.resolve({
|
|
synced: false,
|
|
chatId: "",
|
|
reason: "missing-chat-id",
|
|
});
|
|
}
|
|
|
|
if (!isAutomaticCloudMode(options)) {
|
|
return Promise.resolve({
|
|
synced: false,
|
|
chatId: normalizedChatId,
|
|
action: "manual-probe",
|
|
reason: "manual-cloud-mode",
|
|
remoteStatus: null,
|
|
});
|
|
}
|
|
|
|
return syncNow(normalizedChatId, {
|
|
...options,
|
|
trigger: options.trigger || "chat-change",
|
|
});
|
|
}
|
|
|
|
export function autoSyncOnVisibility(options = {}) {
|
|
if (visibilitySyncInstalled || typeof document?.addEventListener !== "function") {
|
|
return {
|
|
installed: visibilitySyncInstalled,
|
|
};
|
|
}
|
|
|
|
visibilitySyncInstalled = true;
|
|
lastVisibilityState = document.visibilityState || "visible";
|
|
|
|
document.addEventListener("visibilitychange", () => {
|
|
const currentVisibilityState = document.visibilityState || "visible";
|
|
const becameVisible =
|
|
lastVisibilityState === "hidden" && currentVisibilityState === "visible";
|
|
|
|
lastVisibilityState = currentVisibilityState;
|
|
|
|
if (!becameVisible) return;
|
|
|
|
const chatIdResolver =
|
|
typeof options.getCurrentChatId === "function"
|
|
? options.getCurrentChatId
|
|
: () => "";
|
|
|
|
const chatId = normalizeChatId(chatIdResolver());
|
|
if (!chatId) return;
|
|
if (!isAutomaticCloudMode(options)) return;
|
|
|
|
autoSyncOnChatChange(chatId, {
|
|
...options,
|
|
trigger: "visibility-visible",
|
|
}).catch((error) => {
|
|
console.warn("[ST-BME] visibility 自动同步失败:", error);
|
|
});
|
|
});
|
|
|
|
return {
|
|
installed: true,
|
|
};
|
|
}
|
|
|
|
export async function deleteRemoteSyncFile(chatId, options = {}) {
|
|
const normalizedChatId = normalizeChatId(chatId);
|
|
if (!normalizedChatId) {
|
|
return {
|
|
deleted: false,
|
|
chatId: "",
|
|
reason: "missing-chat-id",
|
|
};
|
|
}
|
|
|
|
try {
|
|
const fetchImpl = getFetch(options);
|
|
const filenames = await resolveSyncFilenameCandidates(
|
|
normalizedChatId,
|
|
options,
|
|
);
|
|
let lastNotFoundFilename = filenames[0] || "";
|
|
|
|
for (const filename of filenames) {
|
|
try {
|
|
const manifestPayload = await readRemoteJsonFile(filename, options);
|
|
if (Number(manifestPayload?.formatVersion || 0) === BME_REMOTE_SYNC_FORMAT_VERSION_V2) {
|
|
for (const chunk of Array.isArray(manifestPayload?.chunks) ? manifestPayload.chunks : []) {
|
|
const chunkFilename = String(chunk?.filename || "").trim();
|
|
if (!chunkFilename) continue;
|
|
await fetchImpl("/api/files/delete", {
|
|
method: "POST",
|
|
headers: {
|
|
...getRequestHeadersSafe(options),
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
path: `/user/files/${chunkFilename}`,
|
|
}),
|
|
}).catch(() => null);
|
|
}
|
|
}
|
|
} catch {
|
|
// best-effort chunk cleanup
|
|
}
|
|
const response = await fetchImpl("/api/files/delete", {
|
|
method: "POST",
|
|
headers: {
|
|
...getRequestHeadersSafe(options),
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
path: `/user/files/${filename}`,
|
|
}),
|
|
});
|
|
|
|
if (response.status === 404) {
|
|
lastNotFoundFilename = filename;
|
|
continue;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text().catch(() => response.statusText);
|
|
throw new Error(errorText || `HTTP ${response.status}`);
|
|
}
|
|
|
|
sanitizedFilenameByChatId.delete(normalizedChatId);
|
|
return {
|
|
deleted: true,
|
|
chatId: normalizedChatId,
|
|
filename,
|
|
};
|
|
}
|
|
|
|
return {
|
|
deleted: false,
|
|
chatId: normalizedChatId,
|
|
filename: lastNotFoundFilename,
|
|
reason: "not-found",
|
|
};
|
|
} catch (error) {
|
|
console.warn("[ST-BME] 删除远端同步文件失败:", error);
|
|
return {
|
|
deleted: false,
|
|
chatId: normalizedChatId,
|
|
reason: "delete-error",
|
|
error,
|
|
};
|
|
}
|
|
}
|
|
|
|
export function __testOnlyDecodeBase64Utf8(base64Text) {
|
|
return decodeBase64Utf8(base64Text);
|
|
}
|