From 4ab2e0c3c9d4c5f15b6b4092f27f97479d0cc0a0 Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Wed, 22 Apr 2026 20:08:03 +0800 Subject: [PATCH] perf: add native hydrate wasm path --- index.js | 192 ++++++++++++++++++++++++++ native/stbme-core/src/lib.rs | 65 +++++++++ package.json | 1 + runtime/settings-defaults.js | 2 + scripts/compare-p1-bench.mjs | 25 +++- sync/bme-db.js | 216 +++++++++++++++++++++++++++++- tests/default-settings.mjs | 2 + tests/graph-persistence.mjs | 2 + tests/native-hydrate-failopen.mjs | 57 ++++++++ tests/native-hydrate-hook.mjs | 208 ++++++++++++++++++++++++++++ tests/native-layout-wrapper.mjs | 40 ++++++ tests/perf/persist-load-bench.mjs | 68 +++++++++- vendor/wasm/stbme_core.js | 24 ++++ 13 files changed, 892 insertions(+), 10 deletions(-) create mode 100644 tests/native-hydrate-failopen.mjs create mode 100644 tests/native-hydrate-hook.mjs diff --git a/index.js b/index.js index 569a94c..7aed63c 100644 --- a/index.js +++ b/index.js @@ -23,6 +23,7 @@ import { buildPersistDelta, buildGraphFromSnapshot, buildSnapshotFromGraph, + evaluateNativeHydrateGate, evaluatePersistNativeDeltaGate, ensureDexieLoaded, } from "./sync/bme-db.js"; @@ -1207,6 +1208,7 @@ let isRecalling = false; let activeRecallPromise = null; let recallRunSequence = 0; let nativePersistDeltaInstallPromise = null; +let nativeHydrateInstallPromise = null; let lastInjectionContent = ""; let lastExtractedItems = []; // 最近提取的节点(面板展示用) let lastRecalledItems = []; // 最近召回的节点(面板展示用) @@ -9122,6 +9124,14 @@ function applyIndexedDbSnapshotToRuntime( storageMode = storagePrimary, statusLabel = "IndexedDB", reasonPrefix = "indexeddb", + currentSettings = null, + nativeHydrateRequested = null, + nativeHydrateForceDisabled = null, + nativeHydrateGate = null, + nativeHydratePreloadStatus = "", + nativeHydratePreloadMs = 0, + nativeHydratePreloadError = "", + nativeHydrateModuleStatus = null, } = {}, ) { const normalizedChatId = normalizeChatIdCandidate(chatId); @@ -9221,10 +9231,35 @@ function applyIndexedDbSnapshotToRuntime( } let graphFromSnapshot = null; let hydrateDiagnostics = null; + const effectiveSettings = currentSettings || getSettings(); + const resolvedNativeHydrateRequested = + nativeHydrateRequested == null + ? effectiveSettings.loadUseNativeHydrate === true + : nativeHydrateRequested === true; + const resolvedNativeHydrateForceDisabled = + nativeHydrateForceDisabled == null + ? effectiveSettings.graphNativeForceDisable === true + : nativeHydrateForceDisabled === true; + const resolvedNativeHydrateGate = + nativeHydrateGate && typeof nativeHydrateGate === "object" + ? nativeHydrateGate + : evaluateNativeHydrateGate(snapshot, effectiveSettings); + const shouldUseNativeHydrate = + resolvedNativeHydrateRequested && + resolvedNativeHydrateForceDisabled !== true && + resolvedNativeHydrateGate.allowed; + const resolvedNativeHydratePreloadStatus = String( + nativeHydratePreloadStatus || + (resolvedNativeHydrateRequested ? "not-preloaded" : "not-requested"), + ); try { const hydrateStartedAt = readLoadDiagnosticsNow(); graphFromSnapshot = buildGraphFromSnapshot(snapshot, { chatId: normalizedChatId, + useNativeHydrate: shouldUseNativeHydrate, + nativeFailOpen: effectiveSettings.nativeEngineFailOpen !== false, + loadNativeHydrateThresholdRecords: + effectiveSettings.loadNativeHydrateThresholdRecords, onDiagnostics(snapshotValue) { hydrateDiagnostics = snapshotValue && @@ -9277,6 +9312,17 @@ function applyIndexedDbSnapshotToRuntime( integrityReasons: Array.isArray(error?.reasons) ? error.reasons : [], chatId: normalizedChatId, attemptIndex, + hydrateDiagnostics: cloneRuntimeDebugValue(hydrateDiagnostics, null), + nativeHydrateRequested: resolvedNativeHydrateRequested, + nativeHydrateForceDisabled: resolvedNativeHydrateForceDisabled, + nativeHydrateGate: cloneRuntimeDebugValue(resolvedNativeHydrateGate, null), + nativeHydratePreloadStatus: resolvedNativeHydratePreloadStatus, + nativeHydratePreloadMs: nativeHydratePreloadMs, + nativeHydratePreloadError: nativeHydratePreloadError, + nativeHydrateModuleStatus: cloneRuntimeDebugValue( + nativeHydrateModuleStatus, + null, + ), }; recordLoadDiagnostics({ success: false, @@ -9296,6 +9342,27 @@ function applyIndexedDbSnapshotToRuntime( hydrateIntegrityMs: normalizeLoadDiagnosticsMs( hydrateDiagnostics?.integrityMs, ), + hydrateNativeRequested: resolvedNativeHydrateRequested, + hydrateNativeForceDisabled: resolvedNativeHydrateForceDisabled, + hydrateNativeGateAllowed: resolvedNativeHydrateGate.allowed === true, + hydrateNativeGateReasons: cloneRuntimeDebugValue( + resolvedNativeHydrateGate.reasons, + [], + ), + hydrateNativePreloadStatus: resolvedNativeHydratePreloadStatus, + hydrateNativePreloadMs: normalizeLoadDiagnosticsMs(nativeHydratePreloadMs), + hydrateNativePreloadError: String(nativeHydratePreloadError || ""), + hydrateNativeModuleLoaded: Boolean(nativeHydrateModuleStatus?.loaded), + hydrateNativeModuleSource: String(nativeHydrateModuleStatus?.source || ""), + hydrateNativeModuleError: String( + nativeHydrateModuleStatus?.error || nativeHydratePreloadError || "", + ), + hydrateNativeUsed: hydrateDiagnostics?.nativeUsed === true, + hydrateNativeStatus: String(hydrateDiagnostics?.nativeStatus || ""), + hydrateNativeError: String(hydrateDiagnostics?.nativeError || ""), + hydrateNativeRecordsMs: normalizeLoadDiagnosticsMs( + hydrateDiagnostics?.nativeRecordsMs, + ), error: error?.message || String(error), integrityReasons: Array.isArray(error?.reasons) ? [...error.reasons] : [], }); @@ -9405,6 +9472,17 @@ function applyIndexedDbSnapshotToRuntime( attemptIndex, shadowSnapshotUsed: false, revision, + hydrateDiagnostics: cloneRuntimeDebugValue(hydrateDiagnostics, null), + nativeHydrateRequested: resolvedNativeHydrateRequested, + nativeHydrateForceDisabled: resolvedNativeHydrateForceDisabled, + nativeHydrateGate: cloneRuntimeDebugValue(resolvedNativeHydrateGate, null), + nativeHydratePreloadStatus: resolvedNativeHydratePreloadStatus, + nativeHydratePreloadMs: nativeHydratePreloadMs, + nativeHydratePreloadError: nativeHydratePreloadError, + nativeHydrateModuleStatus: cloneRuntimeDebugValue( + nativeHydrateModuleStatus, + null, + ), }; recordLoadDiagnostics({ success: true, @@ -9424,6 +9502,27 @@ function applyIndexedDbSnapshotToRuntime( hydrateIntegrityMs: normalizeLoadDiagnosticsMs( hydrateDiagnostics?.integrityMs, ), + hydrateNativeRequested: resolvedNativeHydrateRequested, + hydrateNativeForceDisabled: resolvedNativeHydrateForceDisabled, + hydrateNativeGateAllowed: resolvedNativeHydrateGate.allowed === true, + hydrateNativeGateReasons: cloneRuntimeDebugValue( + resolvedNativeHydrateGate.reasons, + [], + ), + hydrateNativePreloadStatus: resolvedNativeHydratePreloadStatus, + hydrateNativePreloadMs: normalizeLoadDiagnosticsMs(nativeHydratePreloadMs), + hydrateNativePreloadError: String(nativeHydratePreloadError || ""), + hydrateNativeModuleLoaded: Boolean(nativeHydrateModuleStatus?.loaded), + hydrateNativeModuleSource: String(nativeHydrateModuleStatus?.source || ""), + hydrateNativeModuleError: String( + nativeHydrateModuleStatus?.error || nativeHydratePreloadError || "", + ), + hydrateNativeUsed: hydrateDiagnostics?.nativeUsed === true, + hydrateNativeStatus: String(hydrateDiagnostics?.nativeStatus || ""), + hydrateNativeError: String(hydrateDiagnostics?.nativeError || ""), + hydrateNativeRecordsMs: normalizeLoadDiagnosticsMs( + hydrateDiagnostics?.nativeRecordsMs, + ), applyRuntimeMs: normalizeLoadDiagnosticsMs( readLoadDiagnosticsNow() - applyRuntimeStartedAt, ), @@ -9458,6 +9557,7 @@ async function loadGraphFromIndexedDb( let exportProbeMs = 0; let preApplyMs = 0; let exportSnapshotSource = ""; + const currentSettings = getSettings(); if (!normalizedChatId) { const result = { success: false, @@ -9917,6 +10017,57 @@ async function loadGraphFromIndexedDb( } cacheIndexedDbSnapshot(normalizedChatId, snapshot); + const nativeHydrateRequested = currentSettings.loadUseNativeHydrate === true; + const nativeHydrateForceDisabled = + currentSettings.graphNativeForceDisable === true; + const nativeHydrateGate = evaluateNativeHydrateGate(snapshot, currentSettings); + const shouldUseNativeHydrate = + nativeHydrateRequested && + nativeHydrateForceDisabled !== true && + nativeHydrateGate.allowed; + let nativeHydrateModuleStatus = null; + let nativeHydratePreloadStatus = nativeHydrateRequested + ? nativeHydrateForceDisabled + ? "force-disabled" + : nativeHydrateGate.allowed + ? "pending" + : "gated-out" + : "not-requested"; + let nativeHydratePreloadError = ""; + let nativeHydratePreloadMs = 0; + if (shouldUseNativeHydrate) { + const preloadStartedAt = readLoadDiagnosticsNow(); + try { + if (!nativeHydrateInstallPromise) { + nativeHydrateInstallPromise = import("./vendor/wasm/stbme_core.js") + .then((module) => module?.installNativeHydrateHook?.()) + .catch((error) => { + nativeHydrateInstallPromise = null; + throw error; + }); + } + nativeHydrateModuleStatus = await nativeHydrateInstallPromise; + nativeHydratePreloadStatus = nativeHydrateModuleStatus?.loaded + ? "loaded" + : "not-loaded"; + nativeHydratePreloadMs = + readLoadDiagnosticsNow() - preloadStartedAt; + } catch (error) { + nativeHydratePreloadStatus = "failed"; + nativeHydratePreloadMs = + readLoadDiagnosticsNow() - preloadStartedAt; + nativeHydratePreloadError = error?.message || String(error); + if (currentSettings.nativeEngineFailOpen !== false) { + console.warn( + "[ST-BME] native hydrate preload failed, fallback to JS hydrate:", + error, + ); + } else { + throw error; + } + } + } + preApplyMs = readLoadDiagnosticsNow() - loadStartedAt; const applyInvokeStartedAt = readLoadDiagnosticsNow(); const loadResult = applyIndexedDbSnapshotToRuntime(normalizedChatId, snapshot, { @@ -9926,6 +10077,14 @@ async function loadGraphFromIndexedDb( storageMode: snapshotStore.storageMode, statusLabel: snapshotStore.statusLabel, reasonPrefix: snapshotStore.reasonPrefix, + currentSettings, + nativeHydrateRequested, + nativeHydrateForceDisabled, + nativeHydrateGate, + nativeHydratePreloadStatus, + nativeHydratePreloadMs, + nativeHydratePreloadError, + nativeHydrateModuleStatus, }); const applyInvokeMs = readLoadDiagnosticsNow() - applyInvokeStartedAt; const totalLoadMs = readLoadDiagnosticsNow() - loadStartedAt; @@ -9952,6 +10111,39 @@ async function loadGraphFromIndexedDb( preApplyOtherMs: normalizeLoadDiagnosticsMs( Math.max(0, preApplyMs - exportSnapshotMs - exportProbeMs), ), + hydrateNativeRequested: loadResult?.nativeHydrateRequested === true, + hydrateNativeForceDisabled: loadResult?.nativeHydrateForceDisabled === true, + hydrateNativeGateAllowed: loadResult?.nativeHydrateGate?.allowed === true, + hydrateNativeGateReasons: cloneRuntimeDebugValue( + loadResult?.nativeHydrateGate?.reasons, + [], + ), + hydrateNativePreloadStatus: String( + loadResult?.nativeHydratePreloadStatus || nativeHydratePreloadStatus || "", + ), + hydrateNativePreloadMs: normalizeLoadDiagnosticsMs( + loadResult?.nativeHydratePreloadMs, + ), + hydrateNativePreloadError: String( + loadResult?.nativeHydratePreloadError || "", + ), + hydrateNativeModuleLoaded: Boolean( + loadResult?.nativeHydrateModuleStatus?.loaded, + ), + hydrateNativeModuleSource: String( + loadResult?.nativeHydrateModuleStatus?.source || "", + ), + hydrateNativeModuleError: String( + loadResult?.nativeHydrateModuleStatus?.error || "", + ), + hydrateNativeUsed: loadResult?.hydrateDiagnostics?.nativeUsed === true, + hydrateNativeStatus: String( + loadResult?.hydrateDiagnostics?.nativeStatus || "", + ), + hydrateNativeError: String(loadResult?.hydrateDiagnostics?.nativeError || ""), + hydrateNativeRecordsMs: normalizeLoadDiagnosticsMs( + loadResult?.hydrateDiagnostics?.nativeRecordsMs, + ), applyInvokeMs: normalizeLoadDiagnosticsMs(applyInvokeMs), untrackedMs: normalizeLoadDiagnosticsMs( Math.max(0, totalLoadMs - loadAccountedMs), diff --git a/native/stbme-core/src/lib.rs b/native/stbme-core/src/lib.rs index 74db058..7a15f7f 100644 --- a/native/stbme-core/src/lib.rs +++ b/native/stbme-core/src/lib.rs @@ -22,6 +22,25 @@ struct LayoutNode { region_rect: RegionRect, } +fn solve_hydrate_records_in_rust(payload: HydrateRecordsPayload) -> HydrateRecordsResult { + let nodes = clone_hydrate_records(payload.nodes); + let edges = clone_hydrate_records(payload.edges); + let node_count = nodes.len(); + let edge_count = edges.len(); + HydrateRecordsResult { + ok: true, + used_native: true, + nodes, + edges, + diagnostics: HydrateRecordsDiagnostics { + solver: "rust-wasm".to_string(), + node_count, + edge_count, + records_normalized: payload.records_normalized, + }, + } +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] struct LayoutEdge { @@ -224,6 +243,36 @@ struct PersistDeltaIdResult { upsert_tombstone_ids: Vec, } +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct HydrateRecordsPayload { + #[serde(default)] + nodes: Vec, + #[serde(default)] + edges: Vec, + #[serde(default)] + records_normalized: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct HydrateRecordsDiagnostics { + solver: String, + node_count: usize, + edge_count: usize, + records_normalized: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct HydrateRecordsResult { + ok: bool, + used_native: bool, + nodes: Vec, + edges: Vec, + diagnostics: HydrateRecordsDiagnostics, +} + fn default_iterations() -> u32 { 80 } @@ -299,6 +348,13 @@ fn sanitize_json_records(records: Vec) -> Vec { .collect() } +fn clone_hydrate_records(records: Vec) -> Vec { + records + .into_iter() + .filter(|record| record.is_object()) + .collect() +} + fn sanitize_persist_snapshot(snapshot: PersistSnapshot) -> PersistSnapshot { PersistSnapshot { meta: snapshot.meta, @@ -1018,3 +1074,12 @@ pub fn build_persist_delta_compact_hash(payload: JsValue) -> Result Result { + let parsed: HydrateRecordsPayload = serde_wasm_bindgen::from_value(payload) + .map_err(|error| JsValue::from_str(&format!("invalid hydrate payload: {error}")))?; + let solved = solve_hydrate_records_in_rust(parsed); + serde_wasm_bindgen::to_value(&solved) + .map_err(|error| JsValue::from_str(&format!("serialize hydrate result failed: {error}"))) +} diff --git a/package.json b/package.json index ff538bb..350eda5 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "bench:graph-layout": "node tests/perf/graph-layout-bench.mjs", "bench:persist-delta": "node tests/perf/persist-delta-bench.mjs", "bench:persist-load": "node tests/perf/persist-load-bench.mjs", + "bench:persist-load:native-hydrate": "node tests/perf/persist-load-bench.mjs --native-hydrate", "bench:load-preapply": "node tests/perf/load-preapply-bench.mjs", "bench:p1-compare": "node scripts/compare-p1-bench.mjs", "bench:native": "npm run bench:graph-layout && npm run bench:persist-delta", diff --git a/runtime/settings-defaults.js b/runtime/settings-defaults.js index 71e6cf2..222d8a4 100644 --- a/runtime/settings-defaults.js +++ b/runtime/settings-defaults.js @@ -122,6 +122,8 @@ export const defaultSettings = { persistNativeDeltaThresholdStructuralDelta: 600, persistNativeDeltaThresholdSerializedChars: 4000000, persistNativeDeltaBridgeMode: "json", + loadUseNativeHydrate: false, + loadNativeHydrateThresholdRecords: 12000, nativeEngineFailOpen: true, graphNativeForceDisable: false, diff --git a/scripts/compare-p1-bench.mjs b/scripts/compare-p1-bench.mjs index cd75ba3..c935b7e 100644 --- a/scripts/compare-p1-bench.mjs +++ b/scripts/compare-p1-bench.mjs @@ -17,6 +17,8 @@ const args = new Map( const baselineRef = String(args.get("--baseline") || "origin/main"); const currentRef = String(args.get("--current") || "HEAD"); const outputJson = args.has("--json"); +const useNativeHydrate = args.has("--native-hydrate"); +const nativeHydrateThreshold = args.get("--native-hydrate-threshold"); async function runCommand(command, commandArgs, cwd) { const { stdout, stderr } = await execFileAsync(command, commandArgs, { @@ -78,9 +80,16 @@ function printRows(rows = [], title = "") { } async function runBenchSuite(cwd) { + const persistLoadArgs = ["tests/perf/persist-load-bench.mjs", "--json"]; + if (useNativeHydrate) { + persistLoadArgs.push("--native-hydrate"); + } + if (nativeHydrateThreshold !== undefined && nativeHydrateThreshold !== true) { + persistLoadArgs.push(`--native-hydrate-threshold=${nativeHydrateThreshold}`); + } const persistLoad = await runCommand( process.execPath, - ["tests/perf/persist-load-bench.mjs", "--json"], + persistLoadArgs, cwd, ); const loadPreapply = await runCommand( @@ -153,6 +162,11 @@ async function main() { baselineSha, currentRef, currentSha, + nativeHydrateRequested: useNativeHydrate, + nativeHydrateThreshold: + nativeHydrateThreshold !== undefined && nativeHydrateThreshold !== true + ? String(nativeHydrateThreshold) + : null, compare, }), ); @@ -161,6 +175,15 @@ async function main() { console.log(`[ST-BME][P1-compare] baseline=${baselineRef} (${baselineSha.slice(0, 7)})`); console.log(`[ST-BME][P1-compare] current=${currentRef} (${currentSha.slice(0, 7)})`); + if (useNativeHydrate) { + console.log( + `[ST-BME][P1-compare] nativeHydrate=on threshold=${ + nativeHydrateThreshold !== undefined && nativeHydrateThreshold !== true + ? nativeHydrateThreshold + : "default" + }`, + ); + } printRows( collectMetricRows(compare, (entry) => entry.opfsCommitMs?.p95, "opfsCommitMs.p95"), diff --git a/sync/bme-db.js b/sync/bme-db.js index 74e2a10..0d51e4e 100644 --- a/sync/bme-db.js +++ b/sync/bme-db.js @@ -19,6 +19,7 @@ const DEFAULT_PERSIST_NATIVE_DELTA_THRESHOLD_RECORDS = 20000; const DEFAULT_PERSIST_NATIVE_DELTA_THRESHOLD_STRUCTURAL_DELTA = 600; const DEFAULT_PERSIST_NATIVE_DELTA_THRESHOLD_SERIALIZED_CHARS = 4000000; const DEFAULT_PERSIST_NATIVE_DELTA_BRIDGE_MODE = "json"; +const DEFAULT_NATIVE_HYDRATE_THRESHOLD_RECORDS = 12000; const SUPPORTED_PERSIST_NATIVE_DELTA_BRIDGE_MODES = new Set(["json", "hash"]); const PERSIST_RECORD_SERIALIZATION_CACHE_LIMIT = 50000; @@ -131,6 +132,52 @@ function estimatePersistPayloadBytes(value = null) { } } +function tryBuildNativeHydrateRecords(snapshotView, options = {}) { + if (options?.useNativeHydrate !== true) { + return { + rawResult: null, + status: "not-requested", + error: "", + }; + } + const nativeBuilder = globalThis.__stBmeNativeHydrateSnapshotRecords; + if (typeof nativeBuilder !== "function") { + if (options?.nativeFailOpen === false) { + throw new Error("native-hydrate-builder-unavailable"); + } + return { + rawResult: null, + status: "builder-unavailable", + error: "native-hydrate-builder-unavailable", + }; + } + + try { + return { + rawResult: nativeBuilder( + { + nodes: toArray(snapshotView?.nodes), + edges: toArray(snapshotView?.edges), + }, + { + recordsNormalized: options?.recordsNormalized === true, + }, + ), + status: "ok", + error: "", + }; + } catch (error) { + if (options?.nativeFailOpen === false) { + throw error; + } + return { + rawResult: null, + status: "builder-error", + error: error?.message || String(error), + }; + } +} + function toPlainData(value, fallbackValue = null) { if (value == null) { return fallbackValue; @@ -303,6 +350,72 @@ function cloneHydrateSnapshotNodeRecords(records = []) { return output; } +function hasSharedHydrateRecordReferences(records = [], sourceRecords = []) { + const normalizedSourceRecords = toArray(sourceRecords); + if (!normalizedSourceRecords.length || !Array.isArray(records) || !records.length) { + return false; + } + const sourceRecordSet = new WeakSet(); + for (let index = 0; index < normalizedSourceRecords.length; index += 1) { + const record = normalizedSourceRecords[index]; + if (!record || typeof record !== "object" || Array.isArray(record)) continue; + sourceRecordSet.add(record); + } + for (let index = 0; index < records.length; index += 1) { + const record = records[index]; + if (!record || typeof record !== "object" || Array.isArray(record)) continue; + if (sourceRecordSet.has(record)) { + return true; + } + } + return false; +} + +function normalizeNativeHydrateRecordArray(records = []) { + const sourceRecords = toArray(records); + if (sourceRecords.length === 0) return []; + const output = new Array(sourceRecords.length); + let writeIndex = 0; + for (let index = 0; index < sourceRecords.length; index += 1) { + const record = sourceRecords[index]; + if (!record || typeof record !== "object" || Array.isArray(record)) continue; + output[writeIndex] = record; + writeIndex += 1; + } + output.length = writeIndex; + return output; +} + +function normalizeNativeHydrateResult(rawResult = null, snapshotView = {}) { + if (!rawResult || typeof rawResult !== "object" || Array.isArray(rawResult)) { + return null; + } + if ( + rawResult.nodes === snapshotView?.nodes || + rawResult.edges === snapshotView?.edges + ) { + return null; + } + const nodes = normalizeNativeHydrateRecordArray(rawResult.nodes); + const edges = normalizeNativeHydrateRecordArray(rawResult.edges); + if ( + hasSharedHydrateRecordReferences(nodes, snapshotView?.nodes) || + hasSharedHydrateRecordReferences(edges, snapshotView?.edges) + ) { + return null; + } + return { + nodes, + edges, + diagnostics: + rawResult.diagnostics && + typeof rawResult.diagnostics === "object" && + !Array.isArray(rawResult.diagnostics) + ? rawResult.diagnostics + : null, + }; +} + function cloneHydrateSnapshotEdgeRecords(records = []) { const sourceRecords = toArray(records); if (sourceRecords.length === 0) return []; @@ -452,6 +565,40 @@ function countPersistSnapshotRecords(snapshot = {}) { ); } +function countHydrateSnapshotRecords(snapshot = {}) { + return toArray(snapshot?.nodes).length + toArray(snapshot?.edges).length; +} + +export function resolveNativeHydrateGateOptions(options = {}) { + return { + minSnapshotRecords: normalizePersistNativeDeltaThreshold( + options?.loadNativeHydrateThresholdRecords ?? + options?.hydrateNativeThresholdRecords ?? + options?.minSnapshotRecords, + DEFAULT_NATIVE_HYDRATE_THRESHOLD_RECORDS, + ), + }; +} + +export function evaluateNativeHydrateGate(snapshot, options = {}) { + const normalizedSnapshot = normalizePersistSnapshotView(snapshot); + const gateOptions = resolveNativeHydrateGateOptions(options); + const recordCount = countHydrateSnapshotRecords(normalizedSnapshot); + const reasons = []; + if ( + gateOptions.minSnapshotRecords > 0 && + recordCount < gateOptions.minSnapshotRecords + ) { + reasons.push("below-min-snapshot-records"); + } + return { + allowed: reasons.length === 0, + reasons, + minSnapshotRecords: gateOptions.minSnapshotRecords, + recordCount, + }; +} + function countPersistSnapshotStructuralDelta(beforeSnapshot = {}, afterSnapshot = {}) { return ( Math.abs(toArray(afterSnapshot?.nodes).length - toArray(beforeSnapshot?.nodes).length) + @@ -2272,6 +2419,14 @@ export function buildGraphFromSnapshot(snapshot, options = {}) { normalizeMs: 0, integrityMs: 0, integrityReasonCount: 0, + nativeRequested: false, + nativeUsed: false, + nativeStatus: "not-requested", + nativeError: "", + nativeRecordsMs: 0, + nativeGateAllowed: false, + nativeGateReasons: [], + nativeModuleDiagnostics: null, } : null; const snapshotView = normalizePersistSnapshotView(snapshot); @@ -2301,6 +2456,58 @@ export function buildGraphFromSnapshot(snapshot, options = {}) { ); const snapshotRecordsNormalized = snapshotMeta?.[BME_RUNTIME_RECORDS_NORMALIZED_META_KEY] === true; + const nativeHydrateGate = + options?.useNativeHydrate === true + ? evaluateNativeHydrateGate(snapshotView, options) + : null; + const nativeHydrateStartedAt = shouldCollectDiagnostics ? readPersistDeltaNow() : 0; + let nativeHydrateAttempt = + options?.useNativeHydrate !== true + ? { + rawResult: null, + status: "not-requested", + error: "", + } + : nativeHydrateGate?.allowed === false + ? { + rawResult: null, + status: "gated-out", + error: "", + } + : tryBuildNativeHydrateRecords( + snapshotView, + { + ...options, + recordsNormalized: snapshotRecordsNormalized, + }, + ); + let nativeHydrateResult = normalizeNativeHydrateResult( + nativeHydrateAttempt.rawResult, + snapshotView, + ); + if (nativeHydrateAttempt.rawResult && !nativeHydrateResult) { + if (options?.nativeFailOpen === false) { + throw new Error("native-hydrate-invalid-result"); + } + nativeHydrateAttempt = { + rawResult: null, + status: "invalid-result", + error: "native-hydrate-invalid-result", + }; + } + if (hydrateDiagnostics) { + hydrateDiagnostics.nativeRequested = options?.useNativeHydrate === true; + hydrateDiagnostics.nativeStatus = nativeHydrateAttempt.status; + hydrateDiagnostics.nativeError = nativeHydrateAttempt.error; + hydrateDiagnostics.nativeGateAllowed = nativeHydrateGate?.allowed ?? false; + hydrateDiagnostics.nativeGateReasons = nativeHydrateGate?.reasons || []; + hydrateDiagnostics.nativeModuleDiagnostics = + nativeHydrateResult?.diagnostics || null; + if (nativeHydrateAttempt.rawResult) { + hydrateDiagnostics.nativeRecordsMs = + readPersistDeltaNow() - nativeHydrateStartedAt; + } + } const runtimeGraph = createEmptyGraph(); runtimeGraph.version = Number.isFinite( @@ -2310,17 +2517,22 @@ export function buildGraphFromSnapshot(snapshot, options = {}) { : runtimeGraph.version; const hydrateNodesStartedAt = shouldCollectDiagnostics ? readPersistDeltaNow() : 0; - runtimeGraph.nodes = cloneHydrateSnapshotNodeRecords(snapshotView.nodes); + runtimeGraph.nodes = nativeHydrateResult + ? nativeHydrateResult.nodes + : cloneHydrateSnapshotNodeRecords(snapshotView.nodes); if (hydrateDiagnostics) { hydrateDiagnostics.nodeCount = runtimeGraph.nodes.length; hydrateDiagnostics.nodesMs = readPersistDeltaNow() - hydrateNodesStartedAt; } const hydrateEdgesStartedAt = shouldCollectDiagnostics ? readPersistDeltaNow() : 0; - runtimeGraph.edges = cloneHydrateSnapshotEdgeRecords(snapshotView.edges); + runtimeGraph.edges = nativeHydrateResult + ? nativeHydrateResult.edges + : cloneHydrateSnapshotEdgeRecords(snapshotView.edges); if (hydrateDiagnostics) { hydrateDiagnostics.edgeCount = runtimeGraph.edges.length; hydrateDiagnostics.edgesMs = readPersistDeltaNow() - hydrateEdgesStartedAt; + hydrateDiagnostics.nativeUsed = Boolean(nativeHydrateResult); } const hydrateRuntimeMetaStartedAt = shouldCollectDiagnostics diff --git a/tests/default-settings.mjs b/tests/default-settings.mjs index b8e9339..00127a9 100644 --- a/tests/default-settings.mjs +++ b/tests/default-settings.mjs @@ -76,6 +76,8 @@ assert.equal(defaultSettings.persistNativeDeltaThresholdRecords, 20000); assert.equal(defaultSettings.persistNativeDeltaThresholdStructuralDelta, 600); assert.equal(defaultSettings.persistNativeDeltaThresholdSerializedChars, 4000000); assert.equal(defaultSettings.persistNativeDeltaBridgeMode, "json"); +assert.equal(defaultSettings.loadUseNativeHydrate, false); +assert.equal(defaultSettings.loadNativeHydrateThresholdRecords, 12000); assert.equal(defaultSettings.nativeEngineFailOpen, true); assert.equal(defaultSettings.graphNativeForceDisable, false); assert.equal(defaultSettings.taskProfilesVersion, 3); diff --git a/tests/graph-persistence.mjs b/tests/graph-persistence.mjs index 438dd36..26301e6 100644 --- a/tests/graph-persistence.mjs +++ b/tests/graph-persistence.mjs @@ -9,6 +9,7 @@ import { buildGraphFromSnapshot, buildPersistDelta, buildSnapshotFromGraph, + evaluateNativeHydrateGate, evaluatePersistNativeDeltaGate, } from "../sync/bme-db.js"; import { onMessageReceivedController } from "../host/event-binding.js"; @@ -1032,6 +1033,7 @@ async function createGraphPersistenceHarness({ buildGraphFromSnapshot, buildPersistDelta, buildSnapshotFromGraph, + evaluateNativeHydrateGate, evaluatePersistNativeDeltaGate, buildBmeDbName, BME_GRAPH_LOCAL_STORAGE_MODE_AUTO: "auto", diff --git a/tests/native-hydrate-failopen.mjs b/tests/native-hydrate-failopen.mjs new file mode 100644 index 0000000..18e4a85 --- /dev/null +++ b/tests/native-hydrate-failopen.mjs @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; + +function moduleUrl(tag) { + return `../vendor/wasm/stbme_core.js?test=${Date.now()}-${tag}`; +} + +globalThis.__stBmeDisableWasmPackArtifacts = true; +delete globalThis.__stBmeLoadRustWasmLayout; + +const firstLoad = await import(moduleUrl("native-hydrate-first")); +let firstError = ""; +try { + await firstLoad.installNativeHydrateHook(); +} catch (error) { + firstError = error?.message || String(error); +} + +assert.match( + firstError, + /native module unavailable|native hydrate builder unavailable|global-loader|Rust\/WASM artifact is not initialized/i, +); + +globalThis.__stBmeLoadRustWasmLayout = async () => ({ + solve_layout() { + return { + ok: true, + positions: [], + diagnostics: { + solver: "mock-rust-wasm", + }, + }; + }, + build_hydrate_records() { + return { + ok: true, + usedNative: true, + nodes: [], + edges: [], + diagnostics: { + solver: "mock-rust-wasm", + nodeCount: 0, + edgeCount: 0, + recordsNormalized: false, + }, + }; + }, +}); + +const retryStatus = await firstLoad.installNativeHydrateHook(); +assert.equal(retryStatus.loaded, true); +assert.equal(typeof globalThis.__stBmeNativeHydrateSnapshotRecords, "function"); + +delete globalThis.__stBmeNativeHydrateSnapshotRecords; +delete globalThis.__stBmeLoadRustWasmLayout; +delete globalThis.__stBmeDisableWasmPackArtifacts; + +console.log("native-hydrate-failopen tests passed"); diff --git a/tests/native-hydrate-hook.mjs b/tests/native-hydrate-hook.mjs new file mode 100644 index 0000000..e3d57b6 --- /dev/null +++ b/tests/native-hydrate-hook.mjs @@ -0,0 +1,208 @@ +import assert from "node:assert/strict"; + +import { + BME_RUNTIME_HISTORY_META_KEY, + BME_RUNTIME_RECORDS_NORMALIZED_META_KEY, + BME_RUNTIME_VECTOR_META_KEY, + buildGraphFromSnapshot, + evaluateNativeHydrateGate, + resolveNativeHydrateGateOptions, +} from "../sync/bme-db.js"; + +function cloneValue(value) { + if (typeof globalThis.structuredClone === "function") { + return globalThis.structuredClone(value); + } + return JSON.parse(JSON.stringify(value)); +} + +const snapshot = { + meta: { + chatId: "chat-native-hydrate", + revision: 3, + [BME_RUNTIME_RECORDS_NORMALIZED_META_KEY]: true, + [BME_RUNTIME_HISTORY_META_KEY]: { + chatId: "chat-native-hydrate", + lastProcessedAssistantFloor: 7, + extractionCount: 2, + processedMessageHashes: {}, + processedMessageHashVersion: 1, + processedMessageHashesNeedRefresh: false, + recentRecallOwnerKeys: [], + activeRecallOwnerKey: "", + activeRegion: "", + activeRegionSource: "", + activeStorySegmentId: "", + activeStoryTimeLabel: "", + activeStoryTimeSource: "", + lastBatchStatus: null, + lastMutationSource: "test", + lastExtractedRegion: "", + lastExtractedStorySegmentId: "", + activeCharacterPovOwner: "", + activeUserPovOwner: "", + }, + [BME_RUNTIME_VECTOR_META_KEY]: { + chatId: "chat-native-hydrate", + collectionId: "", + hashToNodeId: {}, + nodeToHash: {}, + replayRequiredNodeIds: [], + dirty: false, + dirtyReason: "", + pendingRepairFromFloor: null, + lastIntegrityIssue: null, + lastStats: { + nodesIndexed: 0, + updatedAt: 0, + }, + }, + }, + state: { + lastProcessedFloor: 7, + extractionCount: 2, + }, + nodes: [ + { + id: "native-node-1", + type: "event", + updatedAt: 10, + fields: { + title: "Native Node", + }, + embedding: [1, 2, 3], + scope: { + ownerType: "character", + ownerId: "owner-1", + layer: "objective", + regionPrimary: "camp", + regionPath: ["camp"], + regionSecondary: [], + }, + storyTime: { + label: "Dawn", + tense: "unknown", + }, + storyTimeSpan: { + startLabel: "Dawn", + endLabel: "Dawn", + mixed: false, + }, + }, + ], + edges: [ + { + id: "native-edge-1", + fromId: "native-node-1", + toId: "native-node-2", + relation: "related", + scope: { + ownerType: "character", + ownerId: "owner-1", + layer: "objective", + regionPrimary: "camp", + regionPath: ["camp"], + regionSecondary: [], + }, + }, + ], + tombstones: [], +}; + +const defaultGate = resolveNativeHydrateGateOptions({}); +assert.equal(defaultGate.minSnapshotRecords, 12000); +const gatedSmall = evaluateNativeHydrateGate(snapshot, {}); +assert.equal(gatedSmall.allowed, false); +assert.deepEqual(gatedSmall.reasons, ["below-min-snapshot-records"]); +const gatedLarge = evaluateNativeHydrateGate( + { + nodes: new Array(6000).fill({ id: "node-x" }), + edges: new Array(6000).fill({ id: "edge-x" }), + }, + {}, +); +assert.equal(gatedLarge.allowed, true); +assert.deepEqual(gatedLarge.reasons, []); + +const originalNativeBuilder = globalThis.__stBmeNativeHydrateSnapshotRecords; + +globalThis.__stBmeNativeHydrateSnapshotRecords = (snapshotView = {}, options = {}) => { + assert.equal(options.recordsNormalized, true); + return { + ok: true, + usedNative: true, + nodes: cloneValue(snapshotView.nodes).map((node) => ({ + ...node, + nativeHydrated: true, + })), + edges: cloneValue(snapshotView.edges).map((edge) => ({ + ...edge, + nativeHydrated: true, + })), + diagnostics: { + solver: "test-native-hydrate", + nodeCount: Array.isArray(snapshotView.nodes) ? snapshotView.nodes.length : 0, + edgeCount: Array.isArray(snapshotView.edges) ? snapshotView.edges.length : 0, + recordsNormalized: options.recordsNormalized === true, + }, + }; +}; + +let nativeDiagnostics = null; +const rebuilt = buildGraphFromSnapshot(snapshot, { + chatId: "chat-native-hydrate", + useNativeHydrate: true, + minSnapshotRecords: 0, + onDiagnostics(snapshotValue) { + nativeDiagnostics = snapshotValue; + }, +}); +assert.equal(rebuilt.nodes[0].nativeHydrated, true); +assert.equal(rebuilt.edges[0].nativeHydrated, true); +assert.equal(rebuilt.historyState.lastProcessedAssistantFloor, 7); +assert.equal(nativeDiagnostics.nativeRequested, true); +assert.equal(nativeDiagnostics.nativeUsed, true); +assert.equal(nativeDiagnostics.nativeStatus, "ok"); +assert.equal(nativeDiagnostics.nativeGateAllowed, true); +assert.equal(nativeDiagnostics.nativeModuleDiagnostics?.solver, "test-native-hydrate"); +assert.equal(Number.isFinite(nativeDiagnostics.nativeRecordsMs), true); +rebuilt.nodes[0].fields.title = "Mutated Native Node"; +rebuilt.nodes[0].embedding[0] = 99; +assert.equal(snapshot.nodes[0].fields.title, "Native Node"); +assert.equal(snapshot.nodes[0].embedding[0], 1); + +delete globalThis.__stBmeNativeHydrateSnapshotRecords; + +let fallbackDiagnostics = null; +const fallbackGraph = buildGraphFromSnapshot(snapshot, { + chatId: "chat-native-hydrate", + useNativeHydrate: true, + minSnapshotRecords: 0, + onDiagnostics(snapshotValue) { + fallbackDiagnostics = snapshotValue; + }, +}); +assert.equal(fallbackGraph.nodes.length, 1); +assert.equal(fallbackDiagnostics.nativeRequested, true); +assert.equal(fallbackDiagnostics.nativeUsed, false); +assert.equal(fallbackDiagnostics.nativeStatus, "builder-unavailable"); + +let threwUnavailable = false; +try { + buildGraphFromSnapshot(snapshot, { + chatId: "chat-native-hydrate", + useNativeHydrate: true, + minSnapshotRecords: 0, + nativeFailOpen: false, + }); +} catch (error) { + threwUnavailable = + String(error?.message || "") === "native-hydrate-builder-unavailable"; +} +assert.equal(threwUnavailable, true); + +if (typeof originalNativeBuilder === "function") { + globalThis.__stBmeNativeHydrateSnapshotRecords = originalNativeBuilder; +} + +console.log("native-hydrate-hook tests passed"); diff --git a/tests/native-layout-wrapper.mjs b/tests/native-layout-wrapper.mjs index 6e543ce..df3f4d9 100644 --- a/tests/native-layout-wrapper.mjs +++ b/tests/native-layout-wrapper.mjs @@ -22,6 +22,24 @@ try { }, }; }, + build_hydrate_records(payload = {}) { + return { + ok: true, + usedNative: true, + nodes: Array.isArray(payload?.nodes) + ? payload.nodes.map((node) => ({ ...node, nativeHydrated: true })) + : [], + edges: Array.isArray(payload?.edges) + ? payload.edges.map((edge) => ({ ...edge, nativeHydrated: true })) + : [], + diagnostics: { + solver: "mock-loader", + nodeCount: Array.isArray(payload?.nodes) ? payload.nodes.length : 0, + edgeCount: Array.isArray(payload?.edges) ? payload.edges.length : 0, + recordsNormalized: payload?.recordsNormalized === true, + }, + }; + }, build_persist_delta_compact(payload = {}) { return { upsertNodeIds: Array.isArray(payload?.afterNodes?.ids) @@ -105,8 +123,29 @@ try { assert.deepEqual(deltaResult.upsertNodes, [{ id: "persist-native-node", marker: "after-chat" }]); assert.equal(deltaResult.runtimeMetaPatch.native, true); + const hydrateInstallStatus = await wrapper.installNativeHydrateHook(); + assert.equal(hydrateInstallStatus.loaded, true); + assert.equal( + typeof globalThis.__stBmeNativeHydrateSnapshotRecords, + "function", + ); + const hydrateResult = globalThis.__stBmeNativeHydrateSnapshotRecords( + { + nodes: [{ id: "hydrate-node", type: "event" }], + edges: [{ id: "hydrate-edge", fromId: "hydrate-node", toId: "hydrate-node-2" }], + }, + { + recordsNormalized: true, + }, + ); + assert.equal(hydrateResult.ok, true); + assert.equal(hydrateResult.nodes[0].nativeHydrated, true); + assert.equal(hydrateResult.edges[0].nativeHydrated, true); + assert.equal(hydrateResult.diagnostics.recordsNormalized, true); + delete globalThis.__stBmeLoadRustWasmLayout; delete globalThis.__stBmeNativeBuildPersistDelta; + delete globalThis.__stBmeNativeHydrateSnapshotRecords; delete globalThis.__stBmeDisableWasmPackArtifacts; const wrapperNoLoader = await importFreshWrapper("no-loader"); @@ -136,6 +175,7 @@ try { } delete globalThis.__stBmeDisableWasmPackArtifacts; delete globalThis.__stBmeNativeBuildPersistDelta; + delete globalThis.__stBmeNativeHydrateSnapshotRecords; } console.log("native-layout-wrapper tests passed"); diff --git a/tests/perf/persist-load-bench.mjs b/tests/perf/persist-load-bench.mjs index f075edc..1a8451a 100644 --- a/tests/perf/persist-load-bench.mjs +++ b/tests/perf/persist-load-bench.mjs @@ -13,12 +13,27 @@ import { createMemoryOpfsRoot } from "../helpers/memory-opfs.mjs"; const RUNS = 4; const outputJson = process.argv.includes("--json"); +const useNativeHydrate = process.argv.includes("--native-hydrate"); +const nativeHydrateThresholdArg = process.argv.find((entry) => + String(entry || "").startsWith("--native-hydrate-threshold="), +); +const nativeHydrateThresholdRecords = nativeHydrateThresholdArg + ? Math.max( + 0, + Math.floor( + Number(String(nativeHydrateThresholdArg).split("=").slice(1).join("=") || 0) || 0, + ), + ) + : undefined; const SIZE_PRESETS = [ { label: "M", seed: 17, nodeCount: 1200, edgeCount: 3600, churn: 0.08 }, { label: "L", seed: 29, nodeCount: 3600, edgeCount: 10800, churn: 0.1 }, { label: "XL", seed: 43, nodeCount: 7200, edgeCount: 21600, churn: 0.12 }, ]; +let nativeHydratePreloadStatus = useNativeHydrate ? "pending" : "not-requested"; +let nativeHydratePreloadError = ""; + function summarize(values = []) { if (!values.length) { return { avg: 0, p95: 0, min: 0, max: 0 }; @@ -218,6 +233,9 @@ function measureHydrate(snapshot, chatId) { const startedAt = performance.now(); buildGraphFromSnapshot(snapshot, { chatId, + useNativeHydrate, + loadNativeHydrateThresholdRecords: nativeHydrateThresholdRecords, + nativeFailOpen: true, onDiagnostics(snapshotValue) { diagnostics = snapshotValue; }, @@ -266,8 +284,10 @@ async function runPreset(preset) { const hydrateStateSamples = []; const hydrateNormalizeSamples = []; const hydrateIntegritySamples = []; + const hydrateNativeRecordsSamples = []; const walFileWriteSamples = []; const manifestFileWriteSamples = []; + let hydrateNativeUsedRuns = 0; for (let run = 0; run < RUNS; run += 1) { const pair = buildBenchPair({ @@ -306,6 +326,12 @@ async function runPreset(preset) { hydrateStateSamples.push(Number(hydrateResult.diagnostics?.stateMs || 0)); hydrateNormalizeSamples.push(Number(hydrateResult.diagnostics?.normalizeMs || 0)); hydrateIntegritySamples.push(Number(hydrateResult.diagnostics?.integrityMs || 0)); + hydrateNativeRecordsSamples.push( + Number(hydrateResult.diagnostics?.nativeRecordsMs || 0), + ); + if (hydrateResult.diagnostics?.nativeUsed === true) { + hydrateNativeUsedRuns += 1; + } walFileWriteSamples.push(Number(opfsCommitResult.diagnostics?.walFileWriteMs || 0)); manifestFileWriteSamples.push( Number(opfsCommitResult.diagnostics?.manifestFileWriteMs || 0), @@ -321,6 +347,11 @@ async function runPreset(preset) { hydrateStateMs: summarize(hydrateStateSamples), hydrateNormalizeMs: summarize(hydrateNormalizeSamples), hydrateIntegrityMs: summarize(hydrateIntegritySamples), + hydrateNativeRecordsMs: summarize(hydrateNativeRecordsSamples), + hydrateNativeUsedRuns, + nativeHydrateRequested: useNativeHydrate, + nativeHydrateThresholdRecords: + nativeHydrateThresholdRecords == null ? null : nativeHydrateThresholdRecords, hydrateRuntimeMetaMs: summarize(hydrateRuntimeMetaSamples), opfsCommitMs: summarize(opfsCommitSamples), opfsWalFileWriteMs: summarize(walFileWriteSamples), @@ -338,6 +369,8 @@ async function runPreset(preset) { `edgesP95=${result.hydrateEdgesMs.p95.toFixed(2)}ms`, `normalizeP95=${result.hydrateNormalizeMs.p95.toFixed(2)}ms`, `integrityP95=${result.hydrateIntegrityMs.p95.toFixed(2)}ms`, + `nativeRecordsP95=${result.hydrateNativeRecordsMs.p95.toFixed(2)}ms`, + `nativeUsed=${result.hydrateNativeUsedRuns}/${RUNS}`, `runtimeMetaP95=${result.hydrateRuntimeMetaMs.p95.toFixed(2)}ms`, ); console.log( @@ -350,15 +383,36 @@ async function runPreset(preset) { } async function main() { - const results = {}; - for (const preset of SIZE_PRESETS) { - results[preset.label] = await runPreset(preset); + if (useNativeHydrate) { + try { + const nativeModule = await import("../../vendor/wasm/stbme_core.js"); + const nativeStatus = await nativeModule?.installNativeHydrateHook?.(); + nativeHydratePreloadStatus = nativeStatus?.loaded ? "loaded" : "not-loaded"; + nativeHydratePreloadError = String(nativeStatus?.error || ""); + } catch (error) { + nativeHydratePreloadStatus = "failed"; + nativeHydratePreloadError = error?.message || String(error); + console.warn( + "[ST-BME][persist-load-bench] native hydrate preload failed, fallback to JS hydrate:", + error, + ); + } } + const presets = {}; + for (const preset of SIZE_PRESETS) { + presets[preset.label] = await runPreset(preset); + } + const payload = { + runs: RUNS, + nativeHydrateRequested: useNativeHydrate, + nativeHydratePreloadStatus, + nativeHydratePreloadError, + nativeHydrateThresholdRecords: + nativeHydrateThresholdRecords == null ? null : nativeHydrateThresholdRecords, + presets, + }; if (outputJson) { - console.log(JSON.stringify({ - runs: RUNS, - presets: results, - })); + console.log(JSON.stringify(payload)); } } diff --git a/vendor/wasm/stbme_core.js b/vendor/wasm/stbme_core.js index a1cf6b3..ce5b5b6 100644 --- a/vendor/wasm/stbme_core.js +++ b/vendor/wasm/stbme_core.js @@ -69,6 +69,10 @@ async function loadFromWasmPackArtifacts() { return { solve_layout: module.solve_layout, + build_hydrate_records: + typeof module.build_hydrate_records === "function" + ? module.build_hydrate_records + : null, build_persist_delta_compact_hash: typeof module.build_persist_delta_compact_hash === "function" ? module.build_persist_delta_compact_hash @@ -222,6 +226,26 @@ export async function installNativePersistDeltaHook() { return getNativeModuleStatus(); } +export async function installNativeHydrateHook() { + const module = await loadNativeModule({ + forceRetry: shouldRetryNativeLoad(), + }); + if (!module || typeof module.build_hydrate_records !== "function") { + throw new Error("native hydrate builder unavailable"); + } + + globalThis.__stBmeNativeHydrateSnapshotRecords = (snapshotView = {}, options = {}) => { + const raw = module.build_hydrate_records({ + nodes: Array.isArray(snapshotView?.nodes) ? snapshotView.nodes : [], + edges: Array.isArray(snapshotView?.edges) ? snapshotView.edges : [], + recordsNormalized: options?.recordsNormalized === true, + }); + return raw && typeof raw === "object" ? raw : null; + }; + + return getNativeModuleStatus(); +} + export function getNativeModuleStatus() { return { loaded: Boolean(cachedNativeModule),