diff --git a/graph/graph-persistence.js b/graph/graph-persistence.js index 028fa1c..e43672e 100644 --- a/graph/graph-persistence.js +++ b/graph/graph-persistence.js @@ -2,7 +2,10 @@ // 不依赖 index.js 模块级可变状态(currentGraph / graphPersistenceState 等) import { deserializeGraph, getGraphStats, serializeGraph } from "./graph.js"; -import { normalizeGraphRuntimeState } from "../runtime/runtime-state.js"; +import { + cloneGraphPersistDirtyState, + normalizeGraphRuntimeState, +} from "../runtime/runtime-state.js"; // ═══════════════════════════════════════════════════════════ // 常量 @@ -1594,10 +1597,12 @@ export function removeGraphShadowSnapshot(chatId = "") { // ═══════════════════════════════════════════════════════════ export function cloneGraphForPersistence(graph, chatId = "") { - return normalizeGraphRuntimeState( + const clonedGraph = normalizeGraphRuntimeState( deserializeGraph(serializeGraph(graph)), chatId, ); + cloneGraphPersistDirtyState(graph, clonedGraph); + return clonedGraph; } export function shouldPreferShadowSnapshotOverOfficial( diff --git a/graph/graph.js b/graph/graph.js index 1dbfadd..e8e62e5 100644 --- a/graph/graph.js +++ b/graph/graph.js @@ -6,6 +6,10 @@ import { createDefaultHistoryState, createDefaultMaintenanceJournal, createDefaultVectorIndexState, + markGraphPersistEdgeDelete, + markGraphPersistEdgeUpsert, + markGraphPersistNodeDelete, + markGraphPersistNodeUpsert, normalizeGraphRuntimeState, PROCESSED_MESSAGE_HASH_VERSION, } from "../runtime/runtime-state.js"; @@ -138,9 +142,11 @@ export function addNode(graph, node) { const lastNode = sameTypeNodes[sameTypeNodes.length - 1]; lastNode.nextId = node.id; node.prevId = lastNode.id; + markGraphPersistNodeUpsert(graph, lastNode, "add-node-link", "graph.addNode"); } graph.nodes.push(node); + markGraphPersistNodeUpsert(graph, node, "add-node", "graph.addNode"); return node; } @@ -192,6 +198,7 @@ export function updateNode(graph, nodeId, updates) { Object.assign(node, updates); node.updatedAt = nextUpdatedAt; + markGraphPersistNodeUpsert(graph, node, "update-node", "graph.updateNode"); return true; } @@ -213,11 +220,17 @@ export function removeNode(graph, nodeId, visited = new Set()) { // 修复时间链表 if (node.prevId) { const prev = getNode(graph, node.prevId); - if (prev) prev.nextId = node.nextId; + if (prev) { + prev.nextId = node.nextId; + markGraphPersistNodeUpsert(graph, prev, "remove-node-link-prev", "graph.removeNode"); + } } if (node.nextId) { const next = getNode(graph, node.nextId); - if (next) next.prevId = node.prevId; + if (next) { + next.prevId = node.prevId; + markGraphPersistNodeUpsert(graph, next, "remove-node-link-next", "graph.removeNode"); + } } // 递归删除子节点(带环保护) @@ -230,6 +243,7 @@ export function removeNode(graph, nodeId, visited = new Set()) { const parent = getNode(graph, node.parentId); if (parent) { parent.childIds = parent.childIds.filter((id) => id !== normalizedNodeId); + markGraphPersistNodeUpsert(graph, parent, "remove-node-parent-detach", "graph.removeNode"); } } @@ -244,15 +258,24 @@ export function removeNode(graph, nodeId, visited = new Set()) { candidate.childIds = candidate.childIds.filter( (id) => id !== normalizedNodeId, ); + markGraphPersistNodeUpsert(graph, candidate, "remove-node-child-detach", "graph.removeNode"); } // 删除相关边 + const deletedEdgeIds = graph.edges + .filter((e) => e.fromId === normalizedNodeId || e.toId === normalizedNodeId) + .map((edge) => String(edge.id || "").trim()) + .filter(Boolean); graph.edges = graph.edges.filter( (e) => e.fromId !== normalizedNodeId && e.toId !== normalizedNodeId, ); + for (const edgeId of deletedEdgeIds) { + markGraphPersistEdgeDelete(graph, edgeId, "remove-node-edge-cascade", "graph.removeNode"); + } // 删除节点本身 graph.nodes = graph.nodes.filter((n) => n.id !== normalizedNodeId); + markGraphPersistNodeDelete(graph, normalizedNodeId, "remove-node", "graph.removeNode"); return true; } @@ -389,6 +412,7 @@ export function addEdge(graph, edge) { Number(existing.expiredAt || 0), ); } + markGraphPersistEdgeUpsert(graph, existing, "merge-edge", "graph.addEdge"); return existing; } @@ -400,6 +424,7 @@ export function addEdge(graph, edge) { } graph.edges.push(edge); + markGraphPersistEdgeUpsert(graph, edge, "add-edge", "graph.addEdge"); return edge; } @@ -413,6 +438,7 @@ export function removeEdge(graph, edgeId) { const idx = graph.edges.findIndex((e) => e.id === edgeId); if (idx === -1) return false; graph.edges.splice(idx, 1); + markGraphPersistEdgeDelete(graph, edgeId, "remove-edge", "graph.removeEdge"); return true; } @@ -558,7 +584,7 @@ function isEdgeActive(edge, now = Date.now()) { * 将边标记为失效(不删除,保留历史) * @param {object} edge */ -export function invalidateEdge(edge) { +export function invalidateEdge(edge, graph = null) { if (!edge) return; const now = Date.now(); if (!edge.invalidAt) { @@ -568,6 +594,9 @@ export function invalidateEdge(edge) { Number(edge.updatedAt || 0), Number(edge.invalidAt || now), ); + if (graph) { + markGraphPersistEdgeUpsert(graph, edge, "invalidate-edge", "graph.invalidateEdge"); + } } /** diff --git a/index.js b/index.js index 7aed63c..6ffafb3 100644 --- a/index.js +++ b/index.js @@ -21,6 +21,7 @@ import { BmeDatabase, buildBmeDbName, buildPersistDelta, + buildPersistDeltaFromGraphDirtyState, buildGraphFromSnapshot, buildSnapshotFromGraph, evaluateNativeHydrateGate, @@ -263,6 +264,7 @@ import { findJournalRecoveryPoint, markHistoryDirty, normalizeGraphRuntimeState, + pruneGraphPersistDirtyState, PROCESSED_MESSAGE_HASH_VERSION, rebindProcessedHistoryStateToChat, snapshotProcessedMessageHashes, @@ -11138,6 +11140,7 @@ async function persistGraphToConfiguredDurableTier( persistDelta, graphSnapshot, persistSnapshot, + sourceGraph: graph, }); if (indexedDbResult?.saved) { persistGraphCommitMarker(context, { @@ -13634,6 +13637,7 @@ async function saveGraphToIndexedDb( persistDelta = null, graphSnapshot = null, persistSnapshot = null, + sourceGraph = null, } = {}, ) { const normalizedChatId = normalizeChatIdCandidate(chatId); @@ -13709,11 +13713,17 @@ async function saveGraphToIndexedDb( !Array.isArray(persistSnapshot) ? persistSnapshot : null; + const sourceGraphInput = + sourceGraph && typeof sourceGraph === "object" && !Array.isArray(sourceGraph) + ? sourceGraph + : null; const persistGraphInput = detachedGraphSnapshot || graph; let baseSnapshot = null; let snapshot = prebuiltPersistSnapshot; let delta = directPersistDelta; let persistDeltaBuildDiagnostics = null; + let dirtyPersistDeltaVersion = 0; + let dirtyPersistUsed = false; let nativePersistModuleStatus = null; let nativePersistPreloadStatus = "not-requested"; let nativePersistPreloadError = ""; @@ -13731,6 +13741,42 @@ async function saveGraphToIndexedDb( } baseSnapshotReadMs = readPersistDeltaDiagnosticsNow() - baseSnapshotReadStartedAt; + if (persistGraphInput) { + delta = buildPersistDeltaFromGraphDirtyState(baseSnapshot, persistGraphInput, { + chatId: normalizedChatId, + revision: requestedRevision, + lastModified: Date.now(), + meta: { + storagePrimary: localStore.storagePrimary, + storageMode: localStore.storageMode, + lastMutationReason: String(reason || "graph-save"), + integrity: + currentIdentity.integrity || graphPersistenceState.metadataIntegrity, + hostChatId: currentIdentity.hostChatId || "", + }, + onDiagnostics(snapshotValue) { + persistDeltaBuildDiagnostics = + snapshotValue && + typeof snapshotValue === "object" && + !Array.isArray(snapshotValue) + ? snapshotValue + : null; + }, + }); + dirtyPersistUsed = Boolean(delta); + dirtyPersistDeltaVersion = Math.max( + 0, + Math.floor(Number(persistDeltaBuildDiagnostics?.dirtyStateVersion || 0)), + ); + if (dirtyPersistUsed) { + snapshot = applyPersistDeltaToSnapshot(baseSnapshot, delta, { + chatId: normalizedChatId, + revision: requestedRevision, + lastModified: Date.now(), + reason: String(reason || "graph-save"), + }); + } + } if (!snapshot) { const graphSnapshotBuildStartedAt = readPersistDeltaDiagnosticsNow(); snapshot = buildSnapshotFromGraph(persistGraphInput, { @@ -13763,14 +13809,20 @@ async function saveGraphToIndexedDb( currentSettings.persistNativeDeltaBridgeMode || "json", ); const nativePersistRequested = - !directPersistDelta && currentSettings.persistUseNativeDelta === true; + !directPersistDelta && !dirtyPersistUsed && currentSettings.persistUseNativeDelta === true; const nativePersistForceDisabled = currentSettings.graphNativeForceDisable === true; const nativePersistGate = - baseSnapshot && snapshot + !delta && baseSnapshot && snapshot ? evaluatePersistNativeDeltaGate(baseSnapshot, snapshot, currentSettings) : { allowed: false, - reasons: ["direct-delta"], + reasons: [ + directPersistDelta + ? "direct-delta" + : dirtyPersistUsed + ? "dirty-runtime" + : "delta-prebuilt", + ], minSnapshotRecords: Number( currentSettings.persistNativeDeltaThresholdRecords || 0, ), @@ -13803,17 +13855,30 @@ async function saveGraphToIndexedDb( saveReason: String(reason || "graph-save"), requestedRevision, requestedNative: nativePersistRequested, - requestedBridgeMode: directPersistDelta ? "direct-delta" : nativePersistBridgeMode, + requestedBridgeMode: directPersistDelta + ? "direct-delta" + : dirtyPersistUsed + ? "dirty-runtime" + : nativePersistBridgeMode, nativeForceDisabled: nativePersistForceDisabled, nativeFailOpen: currentSettings.nativeEngineFailOpen !== false, - gateAllowed: directPersistDelta ? true : nativePersistGate.allowed, + gateAllowed: directPersistDelta || dirtyPersistUsed ? true : nativePersistGate.allowed, gateReasons: cloneRuntimeDebugValue( - directPersistDelta ? ["direct-delta"] : nativePersistGate.reasons, + directPersistDelta + ? ["direct-delta"] + : dirtyPersistUsed + ? ["dirty-runtime"] + : nativePersistGate.reasons, [], ), - preloadGateAllowed: directPersistDelta ? true : nativePersistGate.allowed, + preloadGateAllowed: + directPersistDelta || dirtyPersistUsed ? true : nativePersistGate.allowed, preloadGateReasons: cloneRuntimeDebugValue( - directPersistDelta ? ["direct-delta"] : nativePersistGate.reasons, + directPersistDelta + ? ["direct-delta"] + : dirtyPersistUsed + ? ["dirty-runtime"] + : nativePersistGate.reasons, [], ), minSnapshotRecords: nativePersistGate.minSnapshotRecords, @@ -13827,7 +13892,11 @@ async function saveGraphToIndexedDb( preloadMs: 0, preloadError: "", status: "building", - path: directPersistDelta ? "direct-delta" : undefined, + path: directPersistDelta + ? "direct-delta" + : dirtyPersistUsed + ? "dirty-runtime" + : undefined, }); if (!directPersistDelta && shouldUseNativePersistDelta) { const preloadStartedAt = readPersistDeltaDiagnosticsNow(); @@ -13876,14 +13945,28 @@ async function saveGraphToIndexedDb( persistDeltaBuildDiagnostics = snapshotValue; }, }); - } else { + } else if (!persistDeltaBuildDiagnostics) { persistDeltaBuildDiagnostics = { requestedNative: false, - requestedBridgeMode: "direct-delta", + requestedBridgeMode: directPersistDelta + ? "direct-delta" + : dirtyPersistUsed + ? "dirty-runtime" + : "prebuilt-delta", usedNative: false, - path: "direct-delta", + path: directPersistDelta + ? "direct-delta" + : dirtyPersistUsed + ? "dirty-runtime" + : "prebuilt-delta", gateAllowed: true, - gateReasons: ["direct-delta"], + gateReasons: [ + directPersistDelta + ? "direct-delta" + : dirtyPersistUsed + ? "dirty-runtime" + : "prebuilt-delta", + ], nativeAttemptStatus: "not-requested", nativeError: "", beforeRecordCount: Number( @@ -13923,6 +14006,7 @@ async function saveGraphToIndexedDb( deleteNodeCount: Number(delta?.deleteNodeIds?.length || 0), deleteEdgeCount: Number(delta?.deleteEdgeIds?.length || 0), tombstoneCount: Number(delta?.tombstones?.length || 0), + dirtyStateVersion: dirtyPersistDeltaVersion, }; } const commitResult = await db.commitDelta(delta, { @@ -13983,6 +14067,13 @@ async function saveGraphToIndexedDb( cacheIndexedDbSnapshot(normalizedChatId, snapshot); } + if (dirtyPersistDeltaVersion > 0) { + pruneGraphPersistDirtyState(graph, dirtyPersistDeltaVersion); + if (sourceGraphInput && sourceGraphInput !== graph) { + pruneGraphPersistDirtyState(sourceGraphInput, dirtyPersistDeltaVersion); + } + } + if (graph === currentGraph) { stampGraphPersistenceMeta(currentGraph, { revision: committedRevision, @@ -14668,6 +14759,7 @@ function queueGraphPersistToIndexedDb( persistDelta, graphSnapshot: persistGraphSnapshot, persistSnapshot, + sourceGraph: graphDetached === true ? null : graph, }); }) .finally(() => { diff --git a/maintenance/extractor.js b/maintenance/extractor.js index 7cea1e4..41d9ec8 100644 --- a/maintenance/extractor.js +++ b/maintenance/extractor.js @@ -1547,7 +1547,7 @@ function handleUpdate( e.fromId === op.sourceNodeId)), ); for (const e of oldEdges) { - invalidateEdge(e); + invalidateEdge(e, graph); } if (op.sourceNodeId && op.sourceNodeId !== op.nodeId) { @@ -1675,7 +1675,7 @@ function invalidateLinksBetween(graph, sourceId, targetId, relation = "related") const sameDirection = edge.fromId === sourceId && edge.toId === targetId; const reverseDirection = edge.fromId === targetId && edge.toId === sourceId; if (!sameDirection && !reverseDirection) continue; - invalidateEdge(edge); + invalidateEdge(edge, graph); changed += 1; } return changed; @@ -1826,10 +1826,7 @@ function buildFieldChangeSummary(previousFields = {}, nextFields = {}) { */ function handleDelete(graph, op, stats) { if (!op.nodeId) return; - const node = graph.nodes.find((n) => n.id === op.nodeId); - if (node) { - node.archived = true; // 软删除 - } + updateNode(graph, op.nodeId, { archived: true }); } function resolveOperationScope( diff --git a/runtime/runtime-state.js b/runtime/runtime-state.js index 5ca427a..4b33ea5 100644 --- a/runtime/runtime-state.js +++ b/runtime/runtime-state.js @@ -23,6 +23,7 @@ const BATCH_JOURNAL_LIMIT = 96; const MAINTENANCE_JOURNAL_LIMIT = 20; export const BATCH_JOURNAL_VERSION = 2; export const PROCESSED_MESSAGE_HASH_VERSION = 2; +const graphPersistDirtyStateByGraph = new WeakMap(); export const MANUAL_BACKUP_BATCH_JOURNAL_COVERAGE_KEY = "manualBackupBatchJournalCoverage"; @@ -225,6 +226,301 @@ function getRequiredJournalCoverageStartFloor(graph, journals = []) { return null; } +function createGraphPersistDirtyState() { + return { + version: 0, + nodeUpserts: new Map(), + edgeUpserts: new Map(), + nodeDeletes: new Map(), + edgeDeletes: new Map(), + runtimeMetaDirty: false, + runtimeMetaVersion: 0, + fullSnapshotRequired: false, + fullSnapshotVersion: 0, + lastReason: "", + lastSource: "", + lastMutationAt: 0, + }; +} + +function getGraphPersistDirtyStateInternal(graph, create = false) { + if (!graph || typeof graph !== "object") { + return null; + } + let state = graphPersistDirtyStateByGraph.get(graph) || null; + if (!state && create) { + state = createGraphPersistDirtyState(); + graphPersistDirtyStateByGraph.set(graph, state); + } + return state; +} + +function bumpGraphPersistDirtyVersion(state, reason = "", source = "") { + if (!state || typeof state !== "object") return 0; + state.version = Math.max(0, Math.floor(Number(state.version || 0))) + 1; + state.lastReason = String(reason || "").trim(); + state.lastSource = String(source || "").trim(); + state.lastMutationAt = Date.now(); + return state.version; +} + +function buildRecordLookupById(records = []) { + const lookup = new Map(); + for (const record of Array.isArray(records) ? records : []) { + if (!record || typeof record !== "object" || Array.isArray(record)) continue; + const id = String(record.id || "").trim(); + if (!id || lookup.has(id)) continue; + lookup.set(id, record); + } + return lookup; +} + +function normalizeDirtyRecordId(recordOrId, recordLookup = null) { + if (recordOrId && typeof recordOrId === "object" && !Array.isArray(recordOrId)) { + return String(recordOrId.id || "").trim(); + } + const normalizedId = String(recordOrId || "").trim(); + if (!normalizedId || !(recordLookup instanceof Map)) return normalizedId; + return recordLookup.has(normalizedId) ? normalizedId : ""; +} + +export function getGraphPersistDirtyStateSnapshot(graph) { + const state = getGraphPersistDirtyStateInternal(graph); + if (!state) return null; + return { + version: Math.max(0, Math.floor(Number(state.version || 0))), + nodeUpsertIds: Array.from(state.nodeUpserts.keys()), + edgeUpsertIds: Array.from(state.edgeUpserts.keys()), + deleteNodeIds: Array.from(state.nodeDeletes.keys()), + deleteEdgeIds: Array.from(state.edgeDeletes.keys()), + runtimeMetaDirty: state.runtimeMetaDirty === true, + runtimeMetaVersion: Math.max(0, Math.floor(Number(state.runtimeMetaVersion || 0))), + fullSnapshotRequired: state.fullSnapshotRequired === true, + fullSnapshotVersion: Math.max(0, Math.floor(Number(state.fullSnapshotVersion || 0))), + lastReason: String(state.lastReason || ""), + lastSource: String(state.lastSource || ""), + lastMutationAt: Math.max(0, Math.floor(Number(state.lastMutationAt || 0))), + }; +} + +export function hasGraphPersistDirtyState(graph) { + const snapshot = getGraphPersistDirtyStateSnapshot(graph); + if (!snapshot) return false; + return ( + snapshot.nodeUpsertIds.length > 0 || + snapshot.edgeUpsertIds.length > 0 || + snapshot.deleteNodeIds.length > 0 || + snapshot.deleteEdgeIds.length > 0 || + snapshot.runtimeMetaDirty === true || + snapshot.fullSnapshotRequired === true + ); +} + +export function cloneGraphPersistDirtyState(sourceGraph, targetGraph) { + const sourceState = getGraphPersistDirtyStateInternal(sourceGraph); + if (!sourceState || !targetGraph || typeof targetGraph !== "object") { + return targetGraph; + } + const targetState = createGraphPersistDirtyState(); + const nodeById = buildRecordLookupById(targetGraph.nodes); + const edgeById = buildRecordLookupById(targetGraph.edges); + + targetState.version = Math.max(0, Math.floor(Number(sourceState.version || 0))); + targetState.runtimeMetaDirty = sourceState.runtimeMetaDirty === true; + targetState.runtimeMetaVersion = Math.max( + 0, + Math.floor(Number(sourceState.runtimeMetaVersion || 0)), + ); + targetState.fullSnapshotRequired = sourceState.fullSnapshotRequired === true; + targetState.fullSnapshotVersion = Math.max( + 0, + Math.floor(Number(sourceState.fullSnapshotVersion || 0)), + ); + targetState.lastReason = String(sourceState.lastReason || ""); + targetState.lastSource = String(sourceState.lastSource || ""); + targetState.lastMutationAt = Math.max( + 0, + Math.floor(Number(sourceState.lastMutationAt || 0)), + ); + + for (const [id, entry] of sourceState.nodeUpserts.entries()) { + const record = nodeById.get(id); + if (!record) continue; + targetState.nodeUpserts.set(id, { + version: Math.max(0, Math.floor(Number(entry?.version || 0))), + record, + }); + } + for (const [id, entry] of sourceState.edgeUpserts.entries()) { + const record = edgeById.get(id); + if (!record) continue; + targetState.edgeUpserts.set(id, { + version: Math.max(0, Math.floor(Number(entry?.version || 0))), + record, + }); + } + for (const [id, version] of sourceState.nodeDeletes.entries()) { + targetState.nodeDeletes.set(id, Math.max(0, Math.floor(Number(version || 0)))); + } + for (const [id, version] of sourceState.edgeDeletes.entries()) { + targetState.edgeDeletes.set(id, Math.max(0, Math.floor(Number(version || 0)))); + } + + graphPersistDirtyStateByGraph.set(targetGraph, targetState); + return targetGraph; +} + +export function pruneGraphPersistDirtyState(graph, committedVersion = 0) { + const state = getGraphPersistDirtyStateInternal(graph); + const normalizedCommittedVersion = Math.max( + 0, + Math.floor(Number(committedVersion || 0)), + ); + if (!state || normalizedCommittedVersion <= 0) { + return getGraphPersistDirtyStateSnapshot(graph); + } + + for (const [id, entry] of state.nodeUpserts.entries()) { + if (Math.max(0, Math.floor(Number(entry?.version || 0))) <= normalizedCommittedVersion) { + state.nodeUpserts.delete(id); + } + } + for (const [id, entry] of state.edgeUpserts.entries()) { + if (Math.max(0, Math.floor(Number(entry?.version || 0))) <= normalizedCommittedVersion) { + state.edgeUpserts.delete(id); + } + } + for (const [id, version] of state.nodeDeletes.entries()) { + if (Math.max(0, Math.floor(Number(version || 0))) <= normalizedCommittedVersion) { + state.nodeDeletes.delete(id); + } + } + for (const [id, version] of state.edgeDeletes.entries()) { + if (Math.max(0, Math.floor(Number(version || 0))) <= normalizedCommittedVersion) { + state.edgeDeletes.delete(id); + } + } + if (state.runtimeMetaDirty && state.runtimeMetaVersion <= normalizedCommittedVersion) { + state.runtimeMetaDirty = false; + state.runtimeMetaVersion = 0; + } + if ( + state.fullSnapshotRequired && + state.fullSnapshotVersion <= normalizedCommittedVersion + ) { + state.fullSnapshotRequired = false; + state.fullSnapshotVersion = 0; + } + if (!hasGraphPersistDirtyState(graph)) { + state.lastReason = ""; + state.lastSource = ""; + state.lastMutationAt = 0; + } + return getGraphPersistDirtyStateSnapshot(graph); +} + +export function markGraphPersistNodeUpsert( + graph, + recordOrId, + reason = "", + source = "", +) { + normalizeGraphRuntimeState(graph, graph?.historyState?.chatId || ""); + const state = getGraphPersistDirtyStateInternal(graph, true); + const recordLookup = buildRecordLookupById(graph?.nodes); + const normalizedId = normalizeDirtyRecordId(recordOrId, recordLookup); + const record = + recordOrId && typeof recordOrId === "object" && !Array.isArray(recordOrId) + ? recordOrId + : recordLookup.get(normalizedId) || null; + if (!normalizedId || !record) return false; + const version = bumpGraphPersistDirtyVersion(state, reason, source); + state.nodeUpserts.set(normalizedId, { version, record }); + state.nodeDeletes.delete(normalizedId); + return true; +} + +export function markGraphPersistEdgeUpsert( + graph, + recordOrId, + reason = "", + source = "", +) { + normalizeGraphRuntimeState(graph, graph?.historyState?.chatId || ""); + const state = getGraphPersistDirtyStateInternal(graph, true); + const recordLookup = buildRecordLookupById(graph?.edges); + const normalizedId = normalizeDirtyRecordId(recordOrId, recordLookup); + const record = + recordOrId && typeof recordOrId === "object" && !Array.isArray(recordOrId) + ? recordOrId + : recordLookup.get(normalizedId) || null; + if (!normalizedId || !record) return false; + const version = bumpGraphPersistDirtyVersion(state, reason, source); + state.edgeUpserts.set(normalizedId, { version, record }); + state.edgeDeletes.delete(normalizedId); + return true; +} + +export function markGraphPersistNodeDelete( + graph, + nodeId, + reason = "", + source = "", +) { + normalizeGraphRuntimeState(graph, graph?.historyState?.chatId || ""); + const normalizedId = String(nodeId || "").trim(); + if (!normalizedId) return false; + const state = getGraphPersistDirtyStateInternal(graph, true); + const version = bumpGraphPersistDirtyVersion(state, reason, source); + state.nodeUpserts.delete(normalizedId); + state.nodeDeletes.set(normalizedId, version); + return true; +} + +export function markGraphPersistEdgeDelete( + graph, + edgeId, + reason = "", + source = "", +) { + normalizeGraphRuntimeState(graph, graph?.historyState?.chatId || ""); + const normalizedId = String(edgeId || "").trim(); + if (!normalizedId) return false; + const state = getGraphPersistDirtyStateInternal(graph, true); + const version = bumpGraphPersistDirtyVersion(state, reason, source); + state.edgeUpserts.delete(normalizedId); + state.edgeDeletes.set(normalizedId, version); + return true; +} + +export function markGraphPersistRuntimeMetaDirty( + graph, + reason = "", + source = "", +) { + normalizeGraphRuntimeState(graph, graph?.historyState?.chatId || ""); + const state = getGraphPersistDirtyStateInternal(graph, true); + const version = bumpGraphPersistDirtyVersion(state, reason, source); + state.runtimeMetaDirty = true; + state.runtimeMetaVersion = version; + return true; +} + +export function markGraphPersistFullSnapshotRequired( + graph, + reason = "", + source = "", +) { + normalizeGraphRuntimeState(graph, graph?.historyState?.chatId || ""); + const state = getGraphPersistDirtyStateInternal(graph, true); + const version = bumpGraphPersistDirtyVersion(state, reason, source); + state.runtimeMetaDirty = true; + state.runtimeMetaVersion = version; + state.fullSnapshotRequired = true; + state.fullSnapshotVersion = version; + return true; +} + export function normalizeGraphRuntimeState(graph, chatId = "", options = {}) { if (!graph || typeof graph !== "object") { return graph; @@ -600,6 +896,11 @@ export function applyProcessedHistorySnapshotToGraph( : {}; historyState.processedMessageHashesNeedRefresh = false; graph.lastProcessedSeq = safeLastProcessedAssistantFloor; + markGraphPersistRuntimeMetaDirty( + graph, + "processed-history-snapshot", + "runtime.history", + ); return graph; } @@ -655,6 +956,11 @@ export function rebindProcessedHistoryStateToChat( : {}; historyState.processedMessageHashesNeedRefresh = false; graph.lastProcessedSeq = safeLastProcessedAssistantFloor; + markGraphPersistRuntimeMetaDirty( + graph, + "history-state-rebound", + "runtime.history", + ); return { rebound: true, @@ -775,6 +1081,7 @@ export function markHistoryDirty(graph, floor, reason = "", source = "") { reason: graph.historyState.lastMutationReason, detectionSource: graph.historyState.lastMutationSource || "", }; + markGraphPersistRuntimeMetaDirty(graph, reason || "history-dirty", source || "runtime.history"); } export function clearHistoryDirty(graph, result = null) { @@ -794,6 +1101,7 @@ export function clearHistoryDirty(graph, result = null) { if (result) { graph.historyState.lastRecoveryResult = result; } + markGraphPersistRuntimeMetaDirty(graph, "history-dirty-cleared", "runtime.history"); } function buildNodeMap(nodes = []) { @@ -1052,6 +1360,11 @@ export function appendBatchJournal(graph, entry) { graph.historyState?.[MANUAL_BACKUP_BATCH_JOURNAL_COVERAGE_KEY], graph.batchJournal, ); + markGraphPersistRuntimeMetaDirty( + graph, + "batch-journal-appended", + "runtime.batch-journal", + ); } export function createMaintenanceJournalEntry( @@ -1158,6 +1471,11 @@ export function appendMaintenanceJournal(graph, entry) { -MAINTENANCE_JOURNAL_LIMIT, ); } + markGraphPersistRuntimeMetaDirty( + graph, + "maintenance-journal-appended", + "runtime.maintenance", + ); } export function getLatestMaintenanceJournalEntry(graph) { @@ -1241,6 +1559,11 @@ export function applyMaintenanceInversePatch(graph, inversePatch = {}) { } sanitizeGraphReferences(graph); + markGraphPersistFullSnapshotRequired( + graph, + "maintenance-inverse-patch", + "runtime.maintenance", + ); return graph; } @@ -1266,6 +1589,7 @@ export function undoLatestMaintenance(graph) { applyMaintenanceInversePatch(graph, entry.inversePatch || {}); graph.maintenanceJournal = graph.maintenanceJournal.slice(0, -1); + markGraphPersistRuntimeMetaDirty(graph, "maintenance-undo", "runtime.maintenance"); return { ok: true, @@ -1387,6 +1711,7 @@ export function rollbackBatch(graph, journal) { applyJournalStateBefore(graph, journal.stateBefore || {}); sanitizeGraphReferences(graph); + markGraphPersistFullSnapshotRequired(graph, "rollback-batch", "runtime.batch-journal"); return graph; } diff --git a/sync/bme-db.js b/sync/bme-db.js index 02ea663..d70533a 100644 --- a/sync/bme-db.js +++ b/sync/bme-db.js @@ -1,6 +1,8 @@ import { createEmptyGraph, deserializeGraph } from "../graph/graph.js"; import { buildVectorCollectionId, + cloneGraphPersistDirtyState, + getGraphPersistDirtyStateSnapshot, normalizeGraphRuntimeState, } from "../runtime/runtime-state.js"; @@ -268,49 +270,89 @@ function cloneHydrateSnapshotStoryTimeSpan(storyTimeSpan = null) { }; } +function isPlainHydrateCloneableObject(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function cloneHydrateSnapshotPropertyValue(key, value) { + switch (key) { + case "fields": + return cloneHydrateSnapshotNestedValue(value, {}); + case "seqRange": + return Array.isArray(value) + ? value.slice() + : cloneHydrateSnapshotNestedValue(value, value); + case "childIds": + return Array.isArray(value) + ? value.slice() + : cloneHydrateSnapshotNestedValue(value, value); + case "clusters": + return Array.isArray(value) + ? value.slice() + : cloneHydrateSnapshotNestedValue(value, value); + case "scope": + return cloneHydrateSnapshotMemoryScope(value); + case "storyTime": + return cloneHydrateSnapshotStoryTime(value); + case "storyTimeSpan": + return cloneHydrateSnapshotStoryTimeSpan(value); + default: + return value != null && typeof value === "object" + ? cloneHydrateSnapshotNestedValue(value, value) + : value; + } +} + +function shouldLazyHydrateCloneProperty(key, value) { + if (value == null || typeof value !== "object") return false; + if (Array.isArray(value)) return true; + switch (key) { + case "fields": + case "scope": + case "storyTime": + case "storyTimeSpan": + return true; + default: + return isPlainHydrateCloneableObject(value); + } +} + +function defineLazyHydrateCloneProperty(target, key, value) { + let materialized = false; + let cachedValue; + Object.defineProperty(target, key, { + configurable: true, + enumerable: true, + get() { + if (!materialized) { + cachedValue = cloneHydrateSnapshotPropertyValue(key, value); + materialized = true; + } + return cachedValue; + }, + set(nextValue) { + cachedValue = nextValue; + materialized = true; + }, + }); +} + function cloneHydrateSnapshotNodeRecord(record = null) { if (!record || typeof record !== "object" || Array.isArray(record)) { return null; } const cloned = {}; - for (const key in record) { - if (!Object.prototype.hasOwnProperty.call(record, key)) continue; + for (const key of Object.keys(record)) { const value = record[key]; - switch (key) { - case "fields": - cloned.fields = cloneHydrateSnapshotNestedValue(value, {}); - break; - case "seqRange": - cloned.seqRange = Array.isArray(value) - ? value.slice() - : cloneHydrateSnapshotNestedValue(value, value); - break; - case "childIds": - cloned.childIds = Array.isArray(value) - ? value.slice() - : cloneHydrateSnapshotNestedValue(value, value); - break; - case "clusters": - cloned.clusters = Array.isArray(value) - ? value.slice() - : cloneHydrateSnapshotNestedValue(value, value); - break; - case "scope": - cloned.scope = cloneHydrateSnapshotMemoryScope(value); - break; - case "storyTime": - cloned.storyTime = cloneHydrateSnapshotStoryTime(value); - break; - case "storyTimeSpan": - cloned.storyTimeSpan = cloneHydrateSnapshotStoryTimeSpan(value); - break; - default: - cloned[key] = - value != null && typeof value === "object" - ? cloneHydrateSnapshotNestedValue(value, value) - : value; - break; + if (shouldLazyHydrateCloneProperty(key, value)) { + defineLazyHydrateCloneProperty(cloned, key, value); + continue; } + cloned[key] = value; } return cloned; } @@ -320,17 +362,13 @@ function cloneHydrateSnapshotEdgeRecord(record = null) { return null; } const cloned = {}; - for (const key in record) { - if (!Object.prototype.hasOwnProperty.call(record, key)) continue; + for (const key of Object.keys(record)) { const value = record[key]; - if (key === "scope") { - cloned.scope = cloneHydrateSnapshotMemoryScope(value); + if (shouldLazyHydrateCloneProperty(key, value)) { + defineLazyHydrateCloneProperty(cloned, key, value); continue; } - cloned[key] = - value != null && typeof value === "object" - ? cloneHydrateSnapshotNestedValue(value, value) - : value; + cloned[key] = value; } return cloned; } @@ -386,6 +424,29 @@ function normalizeNativeHydrateRecordArray(records = []) { return output; } +function decodeNativeHydrateCompactValue(value) { + if (typeof value === "string") { + try { + return JSON.parse(value); + } catch { + return null; + } + } + if ( + typeof TextDecoder === "function" && + ((typeof Uint8Array !== "undefined" && value instanceof Uint8Array) || + (typeof ArrayBuffer !== "undefined" && value instanceof ArrayBuffer)) + ) { + try { + const bytes = value instanceof Uint8Array ? value : new Uint8Array(value); + return JSON.parse(new TextDecoder().decode(bytes)); + } catch { + return null; + } + } + return null; +} + function normalizeNativeHydrateResult(rawResult = null, snapshotView = {}) { if (!rawResult || typeof rawResult !== "object" || Array.isArray(rawResult)) { return null; @@ -396,23 +457,39 @@ function normalizeNativeHydrateResult(rawResult = null, snapshotView = {}) { ) { return null; } - const nodes = normalizeNativeHydrateRecordArray(rawResult.nodes); - const edges = normalizeNativeHydrateRecordArray(rawResult.edges); + const compactPayload = + decodeNativeHydrateCompactValue(rawResult.payloadJson) || + decodeNativeHydrateCompactValue(rawResult.compactJson) || + null; + const rawNodes = + rawResult.nodes ?? + compactPayload?.nodes ?? + decodeNativeHydrateCompactValue(rawResult.nodesJson); + const rawEdges = + rawResult.edges ?? + compactPayload?.edges ?? + decodeNativeHydrateCompactValue(rawResult.edgesJson); + const nodes = normalizeNativeHydrateRecordArray(rawNodes); + const edges = normalizeNativeHydrateRecordArray(rawEdges); if ( hasSharedHydrateRecordReferences(nodes, snapshotView?.nodes) || hasSharedHydrateRecordReferences(edges, snapshotView?.edges) ) { return null; } + const compactBridgeUsed = + rawNodes !== rawResult.nodes || rawEdges !== rawResult.edges; return { nodes, edges, - diagnostics: - rawResult.diagnostics && - typeof rawResult.diagnostics === "object" && - !Array.isArray(rawResult.diagnostics) + diagnostics: { + ...((rawResult.diagnostics && + typeof rawResult.diagnostics === "object" && + !Array.isArray(rawResult.diagnostics) ? rawResult.diagnostics - : null, + : null) || {}), + hydrateBridgeMode: compactBridgeUsed ? "compact-json" : "object", + }, }; } @@ -830,6 +907,7 @@ function buildPersistSnapshotGraphInput(graph = null, chatId = "") { if (chatId) { graphInput.historyState.chatId = chatId; } + cloneGraphPersistDirtyState(sourceGraph, graphInput); return graphInput; } @@ -951,6 +1029,466 @@ function hasReusablePersistTombstoneRecord(baseRecord, normalized = {}) { return true; } +function buildSnapshotRuntimeStateAndMeta( + runtimeGraph, + baseSnapshot = {}, + { + chatId = "", + meta = null, + state: stateOverrides = null, + revision = undefined, + lastModified = undefined, + nodeCount = 0, + edgeCount = 0, + tombstoneCount = 0, + legacyActiveOwnerKey = "", + legacyActiveRegion = "", + legacyActiveSegmentId = "", + } = {}, +) { + const state = { + ...normalizeStateSnapshot(baseSnapshot), + ...(stateOverrides || {}), + lastProcessedFloor: Number.isFinite( + Number(runtimeGraph?.historyState?.lastProcessedAssistantFloor), + ) + ? Number(runtimeGraph.historyState.lastProcessedAssistantFloor) + : Number( + runtimeGraph?.lastProcessedSeq ?? META_DEFAULT_LAST_PROCESSED_FLOOR, + ), + extractionCount: Number.isFinite( + Number(runtimeGraph?.historyState?.extractionCount), + ) + ? Number(runtimeGraph.historyState.extractionCount) + : META_DEFAULT_EXTRACTION_COUNT, + }; + const mergedMeta = { + ...baseSnapshot.meta, + ...(meta || {}), + schemaVersion: BME_DB_SCHEMA_VERSION, + chatId, + revision: normalizeRevision(revision ?? baseSnapshot.meta?.revision), + lastModified: normalizeTimestamp( + lastModified ?? baseSnapshot.meta?.lastModified, + Date.now(), + ), + nodeCount: normalizeNonNegativeInteger(nodeCount, 0), + edgeCount: normalizeNonNegativeInteger(edgeCount, 0), + tombstoneCount: normalizeNonNegativeInteger(tombstoneCount, 0), + [BME_RUNTIME_HISTORY_META_KEY]: toPlainData( + runtimeGraph?.historyState || {}, + {}, + ), + [BME_RUNTIME_VECTOR_META_KEY]: toPlainData( + runtimeGraph?.vectorIndexState || {}, + {}, + ), + [BME_RUNTIME_BATCH_JOURNAL_META_KEY]: toPlainData( + runtimeGraph?.batchJournal || [], + [], + ), + [BME_RUNTIME_LAST_RECALL_META_KEY]: toPlainData( + runtimeGraph?.lastRecallResult ?? null, + null, + ), + [BME_RUNTIME_SUMMARY_STATE_META_KEY]: toPlainData( + runtimeGraph?.summaryState || {}, + {}, + ), + [BME_RUNTIME_MAINTENANCE_JOURNAL_META_KEY]: toPlainData( + runtimeGraph?.maintenanceJournal || [], + [], + ), + [BME_RUNTIME_KNOWLEDGE_STATE_META_KEY]: toPlainData( + { + ...(runtimeGraph?.knowledgeState || {}), + activeOwnerKey: String( + legacyActiveOwnerKey || + runtimeGraph?.historyState?.activeRecallOwnerKey || + "", + ).trim(), + }, + {}, + ), + [BME_RUNTIME_REGION_STATE_META_KEY]: toPlainData( + { + ...(runtimeGraph?.regionState || {}), + activeRegion: String( + legacyActiveRegion || + runtimeGraph?.historyState?.activeRegion || + runtimeGraph?.regionState?.manualActiveRegion || + "", + ).trim(), + }, + {}, + ), + [BME_RUNTIME_TIMELINE_STATE_META_KEY]: toPlainData( + { + ...(runtimeGraph?.timelineState || {}), + activeSegmentId: String( + legacyActiveSegmentId || + runtimeGraph?.historyState?.activeStorySegmentId || + runtimeGraph?.timelineState?.manualActiveSegmentId || + "", + ).trim(), + }, + {}, + ), + [BME_RUNTIME_LAST_PROCESSED_SEQ_META_KEY]: Number.isFinite( + Number(runtimeGraph?.lastProcessedSeq), + ) + ? Number(runtimeGraph.lastProcessedSeq) + : state.lastProcessedFloor, + [BME_RUNTIME_GRAPH_VERSION_META_KEY]: Number.isFinite( + Number(runtimeGraph?.version), + ) + ? Number(runtimeGraph.version) + : Number(baseSnapshot.meta?.[BME_RUNTIME_GRAPH_VERSION_META_KEY] || 0), + [BME_RUNTIME_RECORDS_NORMALIZED_META_KEY]: true, + }; + return { + state, + meta: mergedMeta, + }; +} + +function buildDirtyPersistNodeRecord(node, baseNodeById = new Map(), nowMs = Date.now()) { + if (!node || typeof node !== "object" || Array.isArray(node)) { + return null; + } + const id = normalizeRecordId(node.id); + if (!id) return null; + const normalizedUpdatedAt = normalizeNodeUpdatedAt(node, nowMs); + const baseNode = baseNodeById.get(id); + if ( + hasReusablePersistNodeRecord(baseNode, node, { + type: node.type, + updatedAt: normalizedUpdatedAt, + }) + ) { + return baseNode; + } + const plainNode = clonePersistSnapshotRecord(node); + if (!plainNode || typeof plainNode !== "object" || Array.isArray(plainNode)) { + return null; + } + plainNode.id = id; + plainNode.updatedAt = normalizedUpdatedAt; + return plainNode; +} + +function buildDirtyPersistEdgeRecord(edge, baseEdgeById = new Map(), nowMs = Date.now()) { + if (!edge || typeof edge !== "object" || Array.isArray(edge)) { + return null; + } + const id = normalizeRecordId(edge.id); + if (!id) return null; + const normalizedFromId = normalizeRecordId(edge.fromId); + const normalizedToId = normalizeRecordId(edge.toId); + const normalizedUpdatedAt = normalizeEdgeUpdatedAt(edge, nowMs); + const baseEdge = baseEdgeById.get(id); + if ( + hasReusablePersistEdgeRecord(baseEdge, edge, { + fromId: normalizedFromId, + toId: normalizedToId, + updatedAt: normalizedUpdatedAt, + }) + ) { + return baseEdge; + } + const plainEdge = clonePersistSnapshotRecord(edge); + if (!plainEdge || typeof plainEdge !== "object" || Array.isArray(plainEdge)) { + return null; + } + plainEdge.id = id; + plainEdge.fromId = normalizedFromId; + plainEdge.toId = normalizedToId; + plainEdge.updatedAt = normalizedUpdatedAt; + return plainEdge; +} + +export function buildPersistDeltaFromGraphDirtyState( + baseSnapshotInput, + graph, + options = {}, +) { + const shouldCollectDiagnostics = typeof options?.onDiagnostics === "function"; + const buildStartedAt = shouldCollectDiagnostics ? readPersistDeltaNow() : 0; + const baseSnapshot = sanitizeSnapshot(baseSnapshotInput); + const baseSnapshotView = normalizePersistSnapshotView(baseSnapshotInput); + const nowMs = normalizeTimestamp(options.nowMs, Date.now()); + const chatId = + normalizeChatId(options.chatId) || + normalizeChatId(graph?.historyState?.chatId) || + normalizeChatId(baseSnapshot.meta?.chatId); + const graphInput = buildPersistSnapshotGraphInput(graph, chatId); + const runtimeGraph = normalizeGraphRuntimeState(graphInput, chatId); + const dirtyState = getGraphPersistDirtyStateSnapshot(runtimeGraph); + const baseDiagnostics = { + requestedNative: false, + requestedBridgeMode: "dirty-runtime", + usedNative: false, + path: "dirty-runtime", + gateAllowed: true, + gateReasons: ["dirty-runtime"], + nativeAttemptStatus: "not-requested", + nativeError: "", + beforeRecordCount: + toArray(baseSnapshotView.nodes).length + + toArray(baseSnapshotView.edges).length + + toArray(baseSnapshotView.tombstones).length, + afterRecordCount: + toArray(runtimeGraph?.nodes).length + + toArray(runtimeGraph?.edges).length + + toArray(baseSnapshotView.tombstones).length, + maxSnapshotRecords: 0, + structuralDelta: 0, + beforeSerializedChars: 0, + afterSerializedChars: 0, + combinedSerializedChars: 0, + prepareMs: 0, + nativeAttemptMs: 0, + lookupMs: 0, + jsDiffMs: 0, + hydrateMs: 0, + serializationCacheObjectHits: 0, + serializationCacheTokenHits: 0, + serializationCacheMisses: 0, + serializationCacheHits: 0, + preparedRecordSetCacheHits: 0, + preparedRecordSetCacheMisses: 0, + minCombinedSerializedChars: 0, + upsertNodeCount: 0, + upsertEdgeCount: 0, + deleteNodeCount: 0, + deleteEdgeCount: 0, + tombstoneCount: 0, + dirtyStateVersion: Math.max(0, Math.floor(Number(dirtyState?.version || 0))), + dirtyRuntimeMeta: dirtyState?.runtimeMetaDirty === true, + dirtyRequiresFullSnapshot: dirtyState?.fullSnapshotRequired === true, + }; + if (!dirtyState) { + emitOptionalDiagnostics(options, { + ...baseDiagnostics, + path: "dirty-runtime-miss", + gateAllowed: false, + gateReasons: ["dirty-state-missing"], + buildMs: shouldCollectDiagnostics ? readPersistDeltaNow() - buildStartedAt : 0, + }); + return null; + } + if (dirtyState.fullSnapshotRequired === true) { + emitOptionalDiagnostics(options, { + ...baseDiagnostics, + path: "dirty-runtime-fallback", + gateAllowed: false, + gateReasons: ["full-snapshot-required"], + buildMs: shouldCollectDiagnostics ? readPersistDeltaNow() - buildStartedAt : 0, + }); + return null; + } + const dirtyNodeUpsertIds = Array.isArray(dirtyState.nodeUpsertIds) + ? dirtyState.nodeUpsertIds + : []; + const dirtyEdgeUpsertIds = Array.isArray(dirtyState.edgeUpsertIds) + ? dirtyState.edgeUpsertIds + : []; + const deleteNodeIds = Array.isArray(dirtyState.deleteNodeIds) + ? dirtyState.deleteNodeIds.map((id) => normalizeRecordId(id)).filter(Boolean) + : []; + const deleteEdgeIds = Array.isArray(dirtyState.deleteEdgeIds) + ? dirtyState.deleteEdgeIds.map((id) => normalizeRecordId(id)).filter(Boolean) + : []; + const hasDirtyPayload = + dirtyNodeUpsertIds.length > 0 || + dirtyEdgeUpsertIds.length > 0 || + deleteNodeIds.length > 0 || + deleteEdgeIds.length > 0 || + dirtyState.runtimeMetaDirty === true; + if (!hasDirtyPayload) { + emitOptionalDiagnostics(options, { + ...baseDiagnostics, + path: "dirty-runtime-empty", + gateAllowed: false, + gateReasons: ["dirty-state-empty"], + buildMs: shouldCollectDiagnostics ? readPersistDeltaNow() - buildStartedAt : 0, + }); + return null; + } + + const baseNodeById = buildPersistSnapshotRecordByIdMap(baseSnapshotView.nodes); + const baseEdgeById = buildPersistSnapshotRecordByIdMap(baseSnapshotView.edges); + const baseTombstoneById = buildPersistSnapshotRecordByIdMap( + baseSnapshotView.tombstones, + ); + const runtimeNodeById = buildPersistSnapshotRecordByIdMap(runtimeGraph.nodes); + const runtimeEdgeById = buildPersistSnapshotRecordByIdMap(runtimeGraph.edges); + + const upsertNodes = []; + for (const nodeId of dirtyNodeUpsertIds) { + const node = runtimeNodeById.get(normalizeRecordId(nodeId)); + if (!node) { + emitOptionalDiagnostics(options, { + ...baseDiagnostics, + path: "dirty-runtime-fallback", + gateAllowed: false, + gateReasons: ["missing-dirty-node-record"], + buildMs: shouldCollectDiagnostics ? readPersistDeltaNow() - buildStartedAt : 0, + }); + return null; + } + const plainNode = buildDirtyPersistNodeRecord(node, baseNodeById, nowMs); + if (!plainNode) { + emitOptionalDiagnostics(options, { + ...baseDiagnostics, + path: "dirty-runtime-fallback", + gateAllowed: false, + gateReasons: ["clone-dirty-node-failed"], + buildMs: shouldCollectDiagnostics ? readPersistDeltaNow() - buildStartedAt : 0, + }); + return null; + } + upsertNodes.push(plainNode); + } + + const upsertEdges = []; + for (const edgeId of dirtyEdgeUpsertIds) { + const edge = runtimeEdgeById.get(normalizeRecordId(edgeId)); + if (!edge) { + emitOptionalDiagnostics(options, { + ...baseDiagnostics, + path: "dirty-runtime-fallback", + gateAllowed: false, + gateReasons: ["missing-dirty-edge-record"], + buildMs: shouldCollectDiagnostics ? readPersistDeltaNow() - buildStartedAt : 0, + }); + return null; + } + const plainEdge = buildDirtyPersistEdgeRecord(edge, baseEdgeById, nowMs); + if (!plainEdge) { + emitOptionalDiagnostics(options, { + ...baseDiagnostics, + path: "dirty-runtime-fallback", + gateAllowed: false, + gateReasons: ["clone-dirty-edge-failed"], + buildMs: shouldCollectDiagnostics ? readPersistDeltaNow() - buildStartedAt : 0, + }); + return null; + } + upsertEdges.push(plainEdge); + } + + const sourceDeviceId = normalizeRecordId( + options?.meta?.deviceId || baseSnapshot.meta?.deviceId || "", + ); + const tombstones = []; + const nextTombstoneIds = new Set( + toArray(baseSnapshotView.tombstones) + .map((record) => normalizeRecordId(record?.id)) + .filter(Boolean), + ); + const pushDeleteTombstone = (kind, targetId) => { + const normalizedKind = normalizeRecordId(kind); + const normalizedTargetId = normalizeRecordId(targetId); + if (!normalizedKind || !normalizedTargetId) return; + const tombstoneRecord = { + id: `${normalizedKind}:${normalizedTargetId}`, + kind: normalizedKind, + targetId: normalizedTargetId, + sourceDeviceId, + deletedAt: nowMs, + }; + const baseTombstone = baseTombstoneById.get(tombstoneRecord.id); + if ( + hasReusablePersistTombstoneRecord(baseTombstone, tombstoneRecord) + ) { + nextTombstoneIds.add(tombstoneRecord.id); + return; + } + tombstones.push(tombstoneRecord); + nextTombstoneIds.add(tombstoneRecord.id); + }; + for (const nodeId of deleteNodeIds) { + pushDeleteTombstone("node", nodeId); + } + for (const edgeId of deleteEdgeIds) { + pushDeleteTombstone("edge", edgeId); + } + + const legacyActiveOwnerKey = String( + graphInput?.knowledgeState?.activeOwnerKey || "", + ).trim(); + const legacyActiveRegion = String( + graphInput?.regionState?.activeRegion || "", + ).trim(); + const legacyActiveSegmentId = String( + graphInput?.timelineState?.activeSegmentId || "", + ).trim(); + const runtimeMetaBundle = buildSnapshotRuntimeStateAndMeta(runtimeGraph, baseSnapshot, { + chatId, + meta: options.meta || {}, + state: options.state || {}, + revision: options.revision, + lastModified: options.lastModified ?? nowMs, + nodeCount: toArray(runtimeGraph?.nodes).length, + edgeCount: toArray(runtimeGraph?.edges).length, + tombstoneCount: nextTombstoneIds.size, + legacyActiveOwnerKey, + legacyActiveRegion, + legacyActiveSegmentId, + }); + const runtimeMetaPatch = buildRuntimeMetaPatch({ + meta: runtimeMetaBundle.meta, + state: runtimeMetaBundle.state, + }); + + const previousCounts = { + nodes: toArray(baseSnapshotView.nodes).length, + edges: toArray(baseSnapshotView.edges).length, + tombstones: toArray(baseSnapshotView.tombstones).length, + }; + const nextCounts = { + nodes: toArray(runtimeGraph?.nodes).length, + edges: toArray(runtimeGraph?.edges).length, + tombstones: nextTombstoneIds.size, + }; + const result = { + upsertNodes, + upsertEdges, + deleteNodeIds, + deleteEdgeIds, + tombstones, + runtimeMetaPatch, + countDelta: { + previous: previousCounts, + next: nextCounts, + }, + }; + const diagnostics = { + ...baseDiagnostics, + beforeRecordCount: + previousCounts.nodes + previousCounts.edges + previousCounts.tombstones, + afterRecordCount: nextCounts.nodes + nextCounts.edges + nextCounts.tombstones, + maxSnapshotRecords: Math.max( + previousCounts.nodes + previousCounts.edges + previousCounts.tombstones, + nextCounts.nodes + nextCounts.edges + nextCounts.tombstones, + ), + structuralDelta: + upsertNodes.length + + upsertEdges.length + + deleteNodeIds.length + + deleteEdgeIds.length, + upsertNodeCount: upsertNodes.length, + upsertEdgeCount: upsertEdges.length, + deleteNodeCount: deleteNodeIds.length, + deleteEdgeCount: deleteEdgeIds.length, + tombstoneCount: tombstones.length, + buildMs: shouldCollectDiagnostics ? readPersistDeltaNow() - buildStartedAt : 0, + }; + emitOptionalDiagnostics(options, diagnostics); + return result; +} + export function buildSnapshotFromGraph(graph, options = {}) { const baseSnapshotInput = options?.baseSnapshot && @@ -1134,113 +1672,26 @@ export function buildSnapshotFromGraph(graph, options = {}) { } const stateStartedAt = shouldCollectDiagnostics ? readPersistDeltaNow() : 0; - const state = { - ...normalizeStateSnapshot(baseSnapshot), - ...(options.state || {}), - lastProcessedFloor: Number.isFinite( - Number(runtimeGraph?.historyState?.lastProcessedAssistantFloor), - ) - ? Number(runtimeGraph.historyState.lastProcessedAssistantFloor) - : Number( - runtimeGraph?.lastProcessedSeq ?? META_DEFAULT_LAST_PROCESSED_FLOOR, - ), - extractionCount: Number.isFinite( - Number(runtimeGraph?.historyState?.extractionCount), - ) - ? Number(runtimeGraph.historyState.extractionCount) - : META_DEFAULT_EXTRACTION_COUNT, - }; + const runtimeMetaBundle = buildSnapshotRuntimeStateAndMeta(runtimeGraph, baseSnapshot, { + chatId, + meta: options.meta || {}, + state: options.state || {}, + revision: options.revision, + lastModified: options.lastModified, + nodeCount: nodes.length, + edgeCount: edges.length, + tombstoneCount: tombstones.length, + legacyActiveOwnerKey, + legacyActiveRegion, + legacyActiveSegmentId, + }); + const state = runtimeMetaBundle.state; if (snapshotDiagnostics) { snapshotDiagnostics.stateMs = readPersistDeltaNow() - stateStartedAt; } const metaStartedAt = shouldCollectDiagnostics ? readPersistDeltaNow() : 0; - const mergedMeta = { - ...baseSnapshot.meta, - ...(options.meta || {}), - schemaVersion: BME_DB_SCHEMA_VERSION, - chatId, - revision: normalizeRevision( - options.revision ?? baseSnapshot.meta?.revision, - ), - lastModified: normalizeTimestamp( - options.lastModified ?? baseSnapshot.meta?.lastModified, - nowMs, - ), - nodeCount: nodes.length, - edgeCount: edges.length, - tombstoneCount: tombstones.length, - [BME_RUNTIME_HISTORY_META_KEY]: toPlainData( - runtimeGraph?.historyState || {}, - {}, - ), - [BME_RUNTIME_VECTOR_META_KEY]: toPlainData( - runtimeGraph?.vectorIndexState || {}, - {}, - ), - [BME_RUNTIME_BATCH_JOURNAL_META_KEY]: toPlainData( - runtimeGraph?.batchJournal || [], - [], - ), - [BME_RUNTIME_LAST_RECALL_META_KEY]: toPlainData( - runtimeGraph?.lastRecallResult ?? null, - null, - ), - [BME_RUNTIME_SUMMARY_STATE_META_KEY]: toPlainData( - runtimeGraph?.summaryState || {}, - {}, - ), - [BME_RUNTIME_MAINTENANCE_JOURNAL_META_KEY]: toPlainData( - runtimeGraph?.maintenanceJournal || [], - [], - ), - [BME_RUNTIME_KNOWLEDGE_STATE_META_KEY]: toPlainData( - { - ...(runtimeGraph?.knowledgeState || {}), - activeOwnerKey: String( - legacyActiveOwnerKey || - runtimeGraph?.historyState?.activeRecallOwnerKey || - "", - ).trim(), - }, - {}, - ), - [BME_RUNTIME_REGION_STATE_META_KEY]: toPlainData( - { - ...(runtimeGraph?.regionState || {}), - activeRegion: String( - legacyActiveRegion || - runtimeGraph?.historyState?.activeRegion || - runtimeGraph?.regionState?.manualActiveRegion || - "", - ).trim(), - }, - {}, - ), - [BME_RUNTIME_TIMELINE_STATE_META_KEY]: toPlainData( - { - ...(runtimeGraph?.timelineState || {}), - activeSegmentId: String( - legacyActiveSegmentId || - runtimeGraph?.historyState?.activeStorySegmentId || - runtimeGraph?.timelineState?.manualActiveSegmentId || - "", - ).trim(), - }, - {}, - ), - [BME_RUNTIME_LAST_PROCESSED_SEQ_META_KEY]: Number.isFinite( - Number(runtimeGraph?.lastProcessedSeq), - ) - ? Number(runtimeGraph.lastProcessedSeq) - : state.lastProcessedFloor, - [BME_RUNTIME_GRAPH_VERSION_META_KEY]: Number.isFinite( - Number(runtimeGraph?.version), - ) - ? Number(runtimeGraph.version) - : Number(baseSnapshot.meta?.[BME_RUNTIME_GRAPH_VERSION_META_KEY] || 0), - [BME_RUNTIME_RECORDS_NORMALIZED_META_KEY]: true, - }; + const mergedMeta = runtimeMetaBundle.meta; if (snapshotDiagnostics) { snapshotDiagnostics.metaMs = readPersistDeltaNow() - metaStartedAt; } diff --git a/tests/graph-persistence.mjs b/tests/graph-persistence.mjs index 26edb9e..3b03298 100644 --- a/tests/graph-persistence.mjs +++ b/tests/graph-persistence.mjs @@ -8,6 +8,7 @@ import { buildBmeDbName, buildGraphFromSnapshot, buildPersistDelta, + buildPersistDeltaFromGraphDirtyState, buildSnapshotFromGraph, evaluateNativeHydrateGate, evaluatePersistNativeDeltaGate, @@ -83,13 +84,17 @@ import { getGraphStats, getNode, serializeGraph, + updateNode, } from "../graph/graph.js"; import { buildPersistedRecallRecord, readPersistedRecallFromUserMessage, } from "../retrieval/recall-persistence.js"; import { getNodeDisplayName } from "../graph/node-labels.js"; -import { normalizeGraphRuntimeState } from "../runtime/runtime-state.js"; +import { + normalizeGraphRuntimeState, + pruneGraphPersistDirtyState, +} from "../runtime/runtime-state.js"; import { defaultSettings, getPersistedSettingsSnapshot, @@ -1032,9 +1037,11 @@ async function createGraphPersistenceHarness({ __contextImmediateSaveCalls: 0, buildGraphFromSnapshot, buildPersistDelta, + buildPersistDeltaFromGraphDirtyState, buildSnapshotFromGraph, evaluateNativeHydrateGate, evaluatePersistNativeDeltaGate, + pruneGraphPersistDirtyState, buildBmeDbName, BME_GRAPH_LOCAL_STORAGE_MODE_AUTO: "auto", BME_GRAPH_LOCAL_STORAGE_MODE_INDEXEDDB: "indexeddb", @@ -3303,6 +3310,58 @@ result = { assert.equal(harness.api.getIndexedDbSnapshot()?.meta?.revision, 8); } +{ + const chatId = "chat-idb-dirty-runtime-fast-path"; + const baseGraph = createMeaningfulGraph(chatId, "dirty-runtime-base"); + const runtimeGraph = cloneGraphForPersistence(baseGraph, chatId); + updateNode(runtimeGraph, runtimeGraph.nodes[0]?.id, { + importance: Number(runtimeGraph.nodes[0]?.importance || 0) + 2, + }); + const baseSnapshot = buildSnapshotFromGraph(baseGraph, { + chatId, + revision: 7, + }); + const harness = await createGraphPersistenceHarness({ + chatId, + globalChatId: chatId, + chatMetadata: { + integrity: "meta-idb-dirty-runtime-fast-path", + }, + indexedDbSnapshot: baseSnapshot, + }); + harness.api.setCurrentGraph(runtimeGraph); + harness.api.setGraphPersistenceState({ + loadState: "loaded", + chatId, + revision: 8, + lastPersistedRevision: 0, + writesBlocked: false, + }); + + const originalBuildSnapshotFromGraph = harness.runtimeContext.buildSnapshotFromGraph; + let buildSnapshotCallCount = 0; + harness.runtimeContext.buildSnapshotFromGraph = (...args) => { + buildSnapshotCallCount += 1; + return originalBuildSnapshotFromGraph(...args); + }; + + const result = await harness.api.saveGraphToIndexedDb(chatId, runtimeGraph, { + revision: 8, + reason: "dirty-runtime-fast-path-save", + scheduleCloudUpload: false, + sourceGraph: runtimeGraph, + }); + + assert.equal(result.saved, true); + assert.equal( + buildSnapshotCallCount, + 0, + "dirty-set 命中时 saveGraphToIndexedDb 不应退回 full snapshot build", + ); + assert.equal(result.snapshot?.meta?.revision, 8); + assert.equal(harness.api.getIndexedDbSnapshot()?.meta?.revision, 8); +} + { const chatId = "chat-indexeddb-probe-empty-early-return"; const persistedSnapshot = { diff --git a/tests/index-esm-entry-smoke.mjs b/tests/index-esm-entry-smoke.mjs index 7799358..67860e2 100644 --- a/tests/index-esm-entry-smoke.mjs +++ b/tests/index-esm-entry-smoke.mjs @@ -95,6 +95,8 @@ function resolveCurrentChatIdentity() { } function readCachedIndexedDbSnapshot() { return null; } function resolvePersistRevisionFloor(revision = 0) { return Number(revision) || 1; } +function buildPersistDeltaFromGraphDirtyState() { return null; } +function pruneGraphPersistDirtyState() { return null; } function buildSnapshotFromGraph(graph, options = {}) { return { meta: { diff --git a/tests/native-hydrate-hook.mjs b/tests/native-hydrate-hook.mjs index 421c43c..fd0bdf6 100644 --- a/tests/native-hydrate-hook.mjs +++ b/tests/native-hydrate-hook.mjs @@ -171,6 +171,45 @@ rebuilt.nodes[0].embedding[0] = 99; assert.equal(snapshot.nodes[0].fields.title, "Native Node"); assert.equal(snapshot.nodes[0].embedding[0], 1); +globalThis.__stBmeNativeHydrateSnapshotRecords = (snapshotView = {}, options = {}) => { + assert.equal(options.recordsNormalized, true); + return { + ok: true, + usedNative: true, + nodesJson: JSON.stringify( + cloneValue(snapshotView.nodes).map((node) => ({ + ...node, + compactHydrated: true, + })), + ), + edgesJson: JSON.stringify( + cloneValue(snapshotView.edges).map((edge) => ({ + ...edge, + compactHydrated: true, + })), + ), + diagnostics: { + solver: "test-native-hydrate-compact", + }, + }; +}; + +let compactDiagnostics = null; +const compactGraph = buildGraphFromSnapshot(snapshot, { + chatId: "chat-native-hydrate", + useNativeHydrate: true, + minSnapshotRecords: 0, + onDiagnostics(snapshotValue) { + compactDiagnostics = snapshotValue; + }, +}); +assert.equal(compactGraph.nodes[0].compactHydrated, true); +assert.equal(compactGraph.edges[0].compactHydrated, true); +assert.equal( + compactDiagnostics.nativeModuleDiagnostics?.hydrateBridgeMode, + "compact-json", +); + delete globalThis.__stBmeNativeHydrateSnapshotRecords; let fallbackDiagnostics = null; diff --git a/ui/graph-renderer.js b/ui/graph-renderer.js index 1a11e27..921be61 100644 --- a/ui/graph-renderer.js +++ b/ui/graph-renderer.js @@ -248,6 +248,7 @@ export class GraphRenderer { this._nativeLayoutBridge = null; this._layoutSolveRevision = 0; this._lastLayoutDiagnostics = null; + this._lastLayoutReuseStats = { reused: 0, total: 0, ratio: 0 }; this._regionPanels = []; this._lastGraph = null; @@ -298,6 +299,7 @@ export class GraphRenderer { const loadStartedAt = performance.now(); const prevSelectedId = this.selectedNode?.id || null; const solveRevision = this._nextLayoutSolveRevision(); + const previousLayoutSeedByNodeId = this._captureLayoutSeedByNodeId(); this._nativeLayoutBridge?.cancelPending?.('graph-load-replaced'); this._lastGraph = graph; this._lastLayoutHints = layoutHints && typeof layoutHints === 'object' @@ -352,6 +354,7 @@ export class GraphRenderer { const parts = partitionNodesByScope(this.nodes, this._userPovAliasSet); this._regionPanels = this._computeRegionPanels(W, H, parts); + const layoutReuse = this._applyPreviousLayoutSeed(previousLayoutSeedByNodeId); this._layoutAllPartitions(parts); const layoutFinishedAt = performance.now(); const neuralPlan = this._resolveNeuralSimulationPlan(); @@ -374,6 +377,7 @@ export class GraphRenderer { loadStartedAt, prepareFinishedAt, layoutFinishedAt, + layoutReuse, }, ); } else { @@ -399,6 +403,9 @@ export class GraphRenderer { layoutSeedMs: Math.max(0, layoutFinishedAt - prepareFinishedAt), solveMs, totalMs: Math.max(0, performance.now() - loadStartedAt), + layoutReuseCount: Number(layoutReuse?.reused || 0), + layoutReuseTotal: Number(layoutReuse?.total || this.nodes.length || 0), + layoutReuseRatio: Number(layoutReuse?.ratio || 0), at: Date.now(), }); return; @@ -632,15 +639,78 @@ export class GraphRenderer { } _layoutAllPartitions({ objective, userPov, charMap }) { - this._seedNeuralCloudInRect(objective, objective[0]?.regionRect); + this._seedNeuralCloudInRect( + objective.filter((node) => node._layoutSeedReused !== true), + objective[0]?.regionRect, + ); if (userPov.length) { - this._seedNeuralCloudInRect(userPov, userPov[0]?.regionRect); + this._seedNeuralCloudInRect( + userPov.filter((node) => node._layoutSeedReused !== true), + userPov[0]?.regionRect, + ); } for (const [, arr] of charMap) { - this._seedNeuralCloudInRect(arr, arr[0]?.regionRect); + this._seedNeuralCloudInRect( + arr.filter((node) => node._layoutSeedReused !== true), + arr[0]?.regionRect, + ); } } + _captureLayoutSeedByNodeId() { + const seedByNodeId = new Map(); + for (const node of Array.isArray(this.nodes) ? this.nodes : []) { + if (!node?.id) continue; + if (!Number.isFinite(node.x) || !Number.isFinite(node.y) || !node.regionRect) { + continue; + } + seedByNodeId.set(node.id, { + x: node.x, + y: node.y, + regionKey: node.regionKey || 'objective', + regionRect: { + x: node.regionRect.x, + y: node.regionRect.y, + w: node.regionRect.w, + h: node.regionRect.h, + }, + }); + } + return seedByNodeId; + } + + _applyPreviousLayoutSeed(seedByNodeId = null) { + let reused = 0; + const total = Array.isArray(this.nodes) ? this.nodes.length : 0; + for (const node of this.nodes) { + node._layoutSeedReused = false; + const previousSeed = seedByNodeId instanceof Map ? seedByNodeId.get(node.id) : null; + if (!previousSeed?.regionRect || !node.regionRect) continue; + const nextPosition = remapPositionBetweenRects( + previousSeed.x, + previousSeed.y, + previousSeed.regionRect, + node.regionRect, + ); + if (!Number.isFinite(nextPosition?.x) || !Number.isFinite(nextPosition?.y)) { + continue; + } + node.x = nextPosition.x; + node.y = nextPosition.y; + node.vx = 0; + node.vy = 0; + node._layoutSeedReused = true; + this._clampNodeToRegion(node); + reused += 1; + } + this._lastLayoutReuseStats = { + reused, + total, + ratio: total > 0 ? reused / total : 0, + }; + return this._lastLayoutReuseStats; + } + _rebuildLayoutForCurrentViewport(W, H) { const previousRectsByRegion = new Map(); for (const node of this.nodes) { @@ -724,6 +794,7 @@ export class GraphRenderer { _resolveNeuralSimulationPlan() { const nodeCount = Array.isArray(this.nodes) ? this.nodes.length : 0; const edgeCount = Array.isArray(this.edges) ? this.edges.length : 0; + const reuseRatio = Math.max(0, Math.min(1, Number(this._lastLayoutReuseStats?.ratio || 0))); const baseIterations = Math.max( 8, Math.min(220, Number(this.config.neuralIterations) || 80), @@ -756,6 +827,20 @@ export class GraphRenderer { ); } + if (!skip && nodeCount >= 24) { + if (reuseRatio >= 0.9) { + iterations = Math.min( + iterations, + Math.max(8, Math.round(baseIterations * 0.18)), + ); + } else if (reuseRatio >= 0.65) { + iterations = Math.min( + iterations, + Math.max(10, Math.round(baseIterations * 0.35)), + ); + } + } + return { skip, iterations, @@ -857,6 +942,9 @@ export class GraphRenderer { const loadStartedAt = Number(timings.loadStartedAt) || performance.now(); const prepareFinishedAt = Number(timings.prepareFinishedAt) || loadStartedAt; const layoutFinishedAt = Number(timings.layoutFinishedAt) || prepareFinishedAt; + const layoutReuse = timings.layoutReuse && typeof timings.layoutReuse === 'object' + ? timings.layoutReuse + : this._lastLayoutReuseStats; const bridge = this._ensureNativeLayoutBridge(); const solveStartedAt = performance.now(); @@ -886,6 +974,9 @@ export class GraphRenderer { layoutSeedMs: Math.max(0, layoutFinishedAt - prepareFinishedAt), solveMs: Math.max(0, performance.now() - solveStartedAt), totalMs: Math.max(0, performance.now() - loadStartedAt), + layoutReuseCount: Number(layoutReuse?.reused || 0), + layoutReuseTotal: Number(layoutReuse?.total || this.nodes.length || 0), + layoutReuseRatio: Number(layoutReuse?.ratio || 0), reason: 'stale-layout-result', }, }; @@ -906,6 +997,9 @@ export class GraphRenderer { ? Math.max(0, workerElapsedMs) : 0, totalMs: Math.max(0, performance.now() - loadStartedAt), + layoutReuseCount: Number(layoutReuse?.reused || 0), + layoutReuseTotal: Number(layoutReuse?.total || this.nodes.length || 0), + layoutReuseRatio: Number(layoutReuse?.ratio || 0), reason: '', }, }; @@ -922,6 +1016,9 @@ export class GraphRenderer { layoutSeedMs: Math.max(0, layoutFinishedAt - prepareFinishedAt), solveMs: Math.max(0, performance.now() - solveStartedAt), totalMs: Math.max(0, performance.now() - loadStartedAt), + layoutReuseCount: Number(layoutReuse?.reused || 0), + layoutReuseTotal: Number(layoutReuse?.total || this.nodes.length || 0), + layoutReuseRatio: Number(layoutReuse?.ratio || 0), reason: nativeResult?.reason || 'native-layout-failed', }, }; @@ -941,6 +1038,9 @@ export class GraphRenderer { solveMs: Math.max(0, performance.now() - solveStartedAt) + fallbackSolveMs, fallbackSolveMs, totalMs: Math.max(0, performance.now() - loadStartedAt), + layoutReuseCount: Number(layoutReuse?.reused || 0), + layoutReuseTotal: Number(layoutReuse?.total || this.nodes.length || 0), + layoutReuseRatio: Number(layoutReuse?.ratio || 0), reason: nativeResult?.reason || 'native-layout-failed', }, }; diff --git a/vendor/wasm/stbme_core.js b/vendor/wasm/stbme_core.js index ce5b5b6..98304df 100644 --- a/vendor/wasm/stbme_core.js +++ b/vendor/wasm/stbme_core.js @@ -73,6 +73,10 @@ async function loadFromWasmPackArtifacts() { typeof module.build_hydrate_records === "function" ? module.build_hydrate_records : null, + build_hydrate_records_compact: + typeof module.build_hydrate_records_compact === "function" + ? module.build_hydrate_records_compact + : null, build_persist_delta_compact_hash: typeof module.build_persist_delta_compact_hash === "function" ? module.build_persist_delta_compact_hash @@ -230,16 +234,26 @@ export async function installNativeHydrateHook() { const module = await loadNativeModule({ forceRetry: shouldRetryNativeLoad(), }); - if (!module || typeof module.build_hydrate_records !== "function") { + if ( + !module || + (typeof module.build_hydrate_records !== "function" && + typeof module.build_hydrate_records_compact !== "function") + ) { throw new Error("native hydrate builder unavailable"); } globalThis.__stBmeNativeHydrateSnapshotRecords = (snapshotView = {}, options = {}) => { - const raw = module.build_hydrate_records({ + const hydratePayload = { nodes: Array.isArray(snapshotView?.nodes) ? snapshotView.nodes : [], edges: Array.isArray(snapshotView?.edges) ? snapshotView.edges : [], recordsNormalized: options?.recordsNormalized === true, - }); + preferCompactResult: options?.preferCompactResult !== false, + }; + const raw = + typeof module.build_hydrate_records_compact === "function" && + options?.preferCompactResult !== false + ? module.build_hydrate_records_compact(hydratePayload) + : module.build_hydrate_records(hydratePayload); return raw && typeof raw === "object" ? raw : null; };