const DEFAULT_MAX_BYTES = 1024 * 1024; const DEFAULT_MAX_ITEMS = 128; const DEFAULT_MAX_AGE_MS = 60 * 60 * 1000; function clampInteger(value, fallback, min, max) { const numeric = Number(value); if (!Number.isFinite(numeric)) return fallback; return Math.min(max, Math.max(min, Math.trunc(numeric))); } function normalizeMode(value = "minimal") { const normalized = String(value || "minimal").trim().toLowerCase(); return ["minimal", "off", "emergency-snapshot"].includes(normalized) ? normalized : "minimal"; } function estimateJsonBytes(value) { let serialized = ""; try { serialized = JSON.stringify(value ?? null) || "null"; } catch { serialized = String(value ?? ""); } if (typeof TextEncoder === "function") { return new TextEncoder().encode(serialized).byteLength; } return serialized.length * 2; } function normalizeQueueItem(item = {}, nowMs = Date.now()) { const source = item && typeof item === "object" && !Array.isArray(item) ? item : {}; const payload = source.payload && typeof source.payload === "object" ? source.payload : source; const rawCreatedAt = Number(source.createdAt ?? nowMs); const createdAt = Number.isFinite(rawCreatedAt) ? Math.max(0, rawCreatedAt) : nowMs; const rawBytes = Number(source.bytes ?? estimateJsonBytes(payload)); const bytes = Number.isFinite(rawBytes) ? Math.max(0, rawBytes) : 0; return { id: String(source.id || `offline-${createdAt}-${Math.random().toString(36).slice(2, 8)}`), kind: String(source.kind || "mutation"), reason: String(source.reason || "authority-offline"), createdAt, bytes, payload, }; } function pruneQueue(queue = [], policy = {}, nowMs = Date.now()) { const maxAgeMs = Math.max(0, Number(policy.maxAgeMs || DEFAULT_MAX_AGE_MS) || 0); if (!Array.isArray(queue)) return []; return queue .map((item) => normalizeQueueItem(item, nowMs)) .filter((item) => maxAgeMs <= 0 || nowMs - item.createdAt <= maxAgeMs) .sort((left, right) => left.createdAt - right.createdAt); } function summarizeQueue(queue = []) { const items = Array.isArray(queue) ? queue : []; const bytes = items.reduce((sum, item) => sum + Math.max(0, Number(item?.bytes || 0) || 0), 0); return { items: items.length, bytes, }; } export function getAuthorityBrowserStoragePolicy(settings = {}) { const source = settings && typeof settings === "object" && !Array.isArray(settings) ? settings : {}; return { mode: normalizeMode(source.authorityBrowserCacheMode), offlineWritePolicy: String(source.authorityOfflineWritePolicy || "queue-local-dirty"), maxBytes: clampInteger(source.authorityOfflineQueueMaxBytes, DEFAULT_MAX_BYTES, 0, 64 * 1024 * 1024), maxItems: clampInteger(source.authorityOfflineQueueMaxItems, DEFAULT_MAX_ITEMS, 0, 100000), maxAgeMs: clampInteger(source.authorityOfflineQueueMaxAgeMs, DEFAULT_MAX_AGE_MS, 0, 30 * 24 * 60 * 60 * 1000), }; } export function createAuthorityBrowserState(overrides = {}) { return { mode: "minimal", serverRevision: 0, serverIntegrity: "", lastProbeAt: 0, lastCommitAt: 0, lastError: "", offlineQueue: [], offlineQueueBytes: 0, offlineQueueItems: 0, offlineQueueOverflow: false, offlineQueueOverflowReason: "", updatedAt: "", ...overrides, }; } export function normalizeAuthorityBrowserState(input = {}, settings = {}, nowMs = Date.now()) { const policy = getAuthorityBrowserStoragePolicy(settings); const source = input && typeof input === "object" && !Array.isArray(input) ? input : {}; const queue = pruneQueue(source.offlineQueue, policy, nowMs); const summary = summarizeQueue(queue); return createAuthorityBrowserState({ mode: policy.mode, serverRevision: Math.max(0, Number(source.serverRevision || 0) || 0), serverIntegrity: String(source.serverIntegrity || ""), lastProbeAt: Math.max(0, Number(source.lastProbeAt || 0) || 0), lastCommitAt: Math.max(0, Number(source.lastCommitAt || 0) || 0), lastError: String(source.lastError || ""), offlineQueue: queue, offlineQueueBytes: summary.bytes, offlineQueueItems: summary.items, offlineQueueOverflow: Boolean(source.offlineQueueOverflow), offlineQueueOverflowReason: String(source.offlineQueueOverflowReason || ""), updatedAt: String(source.updatedAt || ""), }); } export function recordAuthorityAcceptedRevision(state = {}, accepted = {}, settings = {}, nowMs = Date.now()) { const current = normalizeAuthorityBrowserState(state, settings, nowMs); return createAuthorityBrowserState({ ...current, serverRevision: Math.max(current.serverRevision, Number(accepted.revision || 0) || 0), serverIntegrity: String(accepted.integrity || current.serverIntegrity || ""), lastCommitAt: Math.max(0, Number(accepted.committedAt || nowMs) || nowMs), lastError: "", updatedAt: new Date(nowMs).toISOString(), }); } export function enqueueAuthorityOfflineMutation(state = {}, mutation = {}, settings = {}, nowMs = Date.now()) { const policy = getAuthorityBrowserStoragePolicy(settings); const current = normalizeAuthorityBrowserState(state, settings, nowMs); if (policy.mode === "off" || policy.offlineWritePolicy === "off") { const nextState = createAuthorityBrowserState({ ...current, offlineQueueOverflow: true, offlineQueueOverflowReason: "offline-queue-disabled", updatedAt: new Date(nowMs).toISOString(), }); return { accepted: false, reason: "offline-queue-disabled", state: nextState }; } const item = normalizeQueueItem(mutation, nowMs); const nextItems = [...current.offlineQueue, item]; const nextSummary = summarizeQueue(nextItems); if (policy.maxItems > 0 && nextSummary.items > policy.maxItems) { console.warn( `[ST-BME] Authority 离线队列溢出 (maxItems=${policy.maxItems}),新突变被丢弃。` + "恢复连接后请手动同步图谱。", { rejectedMutation: item }, ); try { sessionStorage.setItem("st_bme:authority:overflow:global", JSON.stringify({ overflowAt: new Date(nowMs).toISOString(), reason: "max-items-exceeded", lostItemCount: 1, })); } catch {} const nextState = createAuthorityBrowserState({ ...current, offlineQueueOverflow: true, offlineQueueOverflowReason: "max-items-exceeded", updatedAt: new Date(nowMs).toISOString(), }); return { accepted: false, reason: "max-items-exceeded", state: nextState }; } if (policy.maxBytes > 0 && nextSummary.bytes > policy.maxBytes) { console.warn( `[ST-BME] Authority 离线队列溢出 (maxBytes=${policy.maxBytes}),新突变被丢弃。` + "恢复连接后请手动同步图谱。", { rejectedMutation: item }, ); try { sessionStorage.setItem("st_bme:authority:overflow:global", JSON.stringify({ overflowAt: new Date(nowMs).toISOString(), reason: "max-bytes-exceeded", lostItemCount: 1, })); } catch {} const nextState = createAuthorityBrowserState({ ...current, offlineQueueOverflow: true, offlineQueueOverflowReason: "max-bytes-exceeded", updatedAt: new Date(nowMs).toISOString(), }); return { accepted: false, reason: "max-bytes-exceeded", state: nextState }; } const nextState = createAuthorityBrowserState({ ...current, offlineQueue: nextItems, offlineQueueBytes: nextSummary.bytes, offlineQueueItems: nextSummary.items, offlineQueueOverflow: false, offlineQueueOverflowReason: "", updatedAt: new Date(nowMs).toISOString(), }); return { accepted: true, reason: "queued", item, state: nextState }; } export function clearAuthorityOfflineQueue(state = {}, settings = {}, nowMs = Date.now()) { const current = normalizeAuthorityBrowserState(state, settings, nowMs); try { sessionStorage.removeItem("st_bme:authority:overflow:global"); } catch {} return createAuthorityBrowserState({ ...current, offlineQueue: [], offlineQueueBytes: 0, offlineQueueItems: 0, offlineQueueOverflow: false, offlineQueueOverflowReason: "", updatedAt: new Date(nowMs).toISOString(), }); } export function getAuthorityBrowserStateSnapshot(state = {}, settings = {}, nowMs = Date.now()) { const normalized = normalizeAuthorityBrowserState(state, settings, nowMs); return { mode: normalized.mode, serverRevision: normalized.serverRevision, serverIntegrity: normalized.serverIntegrity, lastProbeAt: normalized.lastProbeAt, lastCommitAt: normalized.lastCommitAt, lastError: normalized.lastError, offlineQueueBytes: normalized.offlineQueueBytes, offlineQueueItems: normalized.offlineQueueItems, offlineQueueOverflow: normalized.offlineQueueOverflow, offlineQueueOverflowReason: normalized.offlineQueueOverflowReason, updatedAt: normalized.updatedAt, }; }