diff --git a/web/client/src/store/inventoryStore.js b/web/client/src/store/inventoryStore.js index 0343e73..bd3724f 100644 --- a/web/client/src/store/inventoryStore.js +++ b/web/client/src/store/inventoryStore.js @@ -68,6 +68,8 @@ export const useInventoryStore = create((set, get) => ({ alerts: data.alerts || get().alerts, smeltingPaused: data.smeltingPaused !== undefined ? data.smeltingPaused : get().smeltingPaused, disabledRecipes: data.disabledRecipes || get().disabledRecipes, + smeltable: data.smeltable || get().smeltable, + craftable: data.craftable || get().craftable, craftTurtleOk: data.craftTurtleOk !== undefined ? data.craftTurtleOk : get().craftTurtleOk, lastUpdate: data.lastUpdate || Date.now(), }); diff --git a/web/server/db.js b/web/server/db.js index 137be88..826f0d3 100644 --- a/web/server/db.js +++ b/web/server/db.js @@ -333,14 +333,20 @@ export function loadFullState() { } /** - * Persist the full state to DB in one transaction + * Persist the full state to DB in one transaction. + * Uses a single flat transaction (no nested sub-transactions). */ -export const saveFullState = db.transaction((state) => { - const now = Date.now(); - - // Items +const _saveFullStateTransaction = db.transaction((state, now) => { + // Items — inline loop instead of calling saveItemsBatch to avoid nested transaction if (state.inventoryState?.itemList?.length) { - saveItemsBatch(state.inventoryState.itemList, now); + for (const item of state.inventoryState.itemList) { + upsertItem.run({ + name: item.name || '', + count: item.count || 0, + displayName: item.displayName || item.name || '', + updatedAt: now, + }); + } } // Inventory metadata (totals, slot info, etc.) @@ -349,14 +355,33 @@ export const saveFullState = db.transaction((state) => { upsertState.run({ key: 'inventoryMeta', value: JSON.stringify(meta), updatedAt: now }); } - // Furnaces + // Furnaces — inline loop if (state.inventoryState?.furnaceStatus && typeof state.inventoryState.furnaceStatus === 'object') { - saveFurnacesBatch(state.inventoryState.furnaceStatus, now); + for (const [name, f] of Object.entries(state.inventoryState.furnaceStatus)) { + upsertFurnace.run({ + name, + type: f.type || 'minecraft:furnace', + active: f.active ? 1 : 0, + input: f.input || null, + fuel: f.fuel || null, + output: f.output || null, + updatedAt: now, + }); + } } - // Alerts + // Alerts — inline loop if (Array.isArray(state.alertsState)) { - saveAlertsBatch(state.alertsState, now); + clearAlertTriggered.run(); + for (const alert of state.alertsState) { + upsertAlert.run({ + item: alert.item || '', + triggered: alert.triggered ? 1 : 0, + current: alert.current || 0, + threshold: alert.threshold || 0, + updatedAt: now, + }); + } } // Key-value state @@ -381,10 +406,56 @@ export const saveFullState = db.transaction((state) => { upsertState.run({ key: 'lastUpdate', value: JSON.stringify(now), updatedAt: now }); }); +/** + * Debounced save — buffers rapid updates and writes to DB at most every SAVE_INTERVAL ms. + * This prevents DB writes from blocking WebSocket broadcasts on every bridge update. + */ +const SAVE_INTERVAL = 2000; // Write at most every 2 seconds +let pendingSaveState = null; +let saveTimer = null; + +export function saveFullState(state) { + pendingSaveState = state; + if (!saveTimer) { + saveTimer = setTimeout(() => { + saveTimer = null; + if (pendingSaveState) { + const toSave = pendingSaveState; + pendingSaveState = null; + try { + _saveFullStateTransaction(toSave, Date.now()); + } catch (err) { + console.error('❌ DB save error:', err.message); + } + } + }, SAVE_INTERVAL); + } +} + +/** + * Flush any pending save immediately (used during shutdown) + */ +export function flushPendingSave() { + if (saveTimer) { + clearTimeout(saveTimer); + saveTimer = null; + } + if (pendingSaveState) { + const toSave = pendingSaveState; + pendingSaveState = null; + try { + _saveFullStateTransaction(toSave, Date.now()); + } catch (err) { + console.error('❌ DB flush error:', err.message); + } + } +} + /** * Close the database connection gracefully */ export function closeDb() { + flushPendingSave(); db.close(); } diff --git a/web/server/server.js b/web/server/server.js index 2745ce6..31c9b87 100644 --- a/web/server/server.js +++ b/web/server/server.js @@ -5,7 +5,7 @@ import { createServer } from 'http'; import { loadFullState, saveFullState, recordItemHistory, saveItems, saveFurnaces, saveAlerts, saveState, - getHistory, closeDb, + getHistory, closeDb, flushPendingSave, } from './db.js'; const app = express(); @@ -378,27 +378,7 @@ function updateStateFromBridge(data) { lastUpdate = Date.now(); - // Persist to SQLite - try { - saveFullState({ - inventoryState, - activityState, - alertsState, - smeltingPaused, - disabledRecipes, - smeltableRecipes, - craftableRecipes, - craftTurtleOk, - }); - // Record history snapshot (throttled to every 5 min internally) - if (inventoryState.itemList?.length) { - recordItemHistory(inventoryState.itemList); - } - } catch (err) { - console.error('❌ DB save error:', err.message); - } - - // Broadcast to all web clients + // Broadcast to all web clients FIRST (non-blocking, instant) broadcastToClients({ type: 'state_update', inventory: inventoryState, @@ -406,9 +386,28 @@ function updateStateFromBridge(data) { alerts: alertsState, smeltingPaused, disabledRecipes, + smeltable: smeltableRecipes, + craftable: craftableRecipes, craftTurtleOk, lastUpdate, }); + + // Persist to SQLite (debounced — won't block the broadcast) + saveFullState({ + inventoryState, + activityState, + alertsState, + smeltingPaused, + disabledRecipes, + smeltableRecipes, + craftableRecipes, + craftTurtleOk, + }); + + // Record history snapshot (throttled to every 5 min internally) + if (inventoryState.itemList?.length) { + recordItemHistory(inventoryState.itemList); + } } // ========== WebSocket Server ==========