|
1 | 1 | import { createLogger } from '@sim/logger' |
2 | | -import { del, get, set } from 'idb-keyval' |
| 2 | +import { get, set } from 'idb-keyval' |
3 | 3 | import type { ConsoleEntry } from './types' |
4 | 4 |
|
5 | 5 | const logger = createLogger('ConsoleStorage') |
6 | 6 |
|
7 | 7 | const STORE_KEY = 'terminal-console-store' |
8 | 8 | const MIGRATION_KEY = 'terminal-console-store-migrated' |
9 | | -const WRITE_DEBOUNCE_MS = 750 |
| 9 | + |
| 10 | +/** |
| 11 | + * Safety-net interval for persisting during very long executions. |
| 12 | + * Only fires while an execution is active. Much longer than a debounce |
| 13 | + * because intermediate writes during execution are low-value. |
| 14 | + */ |
| 15 | +const LONG_EXECUTION_PERSIST_INTERVAL_MS = 30_000 |
10 | 16 |
|
11 | 17 | /** |
12 | 18 | * Shape of terminal console data persisted to IndexedDB. |
@@ -88,63 +94,95 @@ export async function loadConsoleData(): Promise<PersistedConsoleData | null> { |
88 | 94 | } |
89 | 95 | } |
90 | 96 |
|
91 | | -let pendingData: PersistedConsoleData | null = null |
92 | | -let writeTimer: ReturnType<typeof setTimeout> | null = null |
| 97 | +let writeSequence = 0 |
| 98 | +let activeWrite: Promise<void> | null = null |
93 | 99 |
|
94 | | -function executeWrite(): void { |
95 | | - writeTimer = null |
96 | | - const data = pendingData |
97 | | - pendingData = null |
98 | | - if (!data) return |
| 100 | +function writeToIndexedDB(data: PersistedConsoleData): void { |
| 101 | + const seq = ++writeSequence |
99 | 102 |
|
100 | | - try { |
101 | | - const serialized = JSON.stringify(data) |
102 | | - set(STORE_KEY, serialized).catch((error) => { |
| 103 | + const doWrite = async () => { |
| 104 | + try { |
| 105 | + const serialized = JSON.stringify(data) |
| 106 | + if (seq !== writeSequence) return |
| 107 | + await set(STORE_KEY, serialized) |
| 108 | + } catch (error) { |
103 | 109 | logger.warn('IndexedDB write failed', { error }) |
104 | | - }) |
105 | | - } catch (error) { |
106 | | - logger.warn('Failed to serialize console data for persistence', { error }) |
| 110 | + } |
107 | 111 | } |
108 | | -} |
109 | 112 |
|
110 | | -/** |
111 | | - * Schedules a debounced write of console data to IndexedDB. |
112 | | - * Only stores a reference until the timer fires, so no serialization |
113 | | - * happens on the calling thread. |
114 | | - */ |
115 | | -export function scheduleConsolePersist(data: PersistedConsoleData): void { |
116 | | - if (typeof window === 'undefined') return |
117 | | - pendingData = data |
118 | | - if (writeTimer !== null) return |
119 | | - writeTimer = setTimeout(executeWrite, WRITE_DEBOUNCE_MS) |
| 113 | + activeWrite = (activeWrite ?? Promise.resolve()).then(doWrite) |
120 | 114 | } |
121 | 115 |
|
122 | 116 | /** |
123 | | - * Immediately flushes any pending console data to IndexedDB. |
124 | | - * Used on page hide to avoid data loss. |
| 117 | + * Execution-aware persistence manager for the terminal console store. |
| 118 | + * |
| 119 | + * Writes happen only at meaningful lifecycle boundaries: |
| 120 | + * - When an execution ends (success, error, cancel) |
| 121 | + * - On explicit user actions (clear console) |
| 122 | + * - On page hide (crash safety) |
| 123 | + * - Every 30s during very long active executions (safety net) |
| 124 | + * |
| 125 | + * During normal execution, no serialization or IndexedDB writes occur, |
| 126 | + * keeping the hot path completely free of persistence overhead. |
125 | 127 | */ |
126 | | -export function flushConsolePersist(): void { |
127 | | - if (writeTimer !== null) { |
128 | | - clearTimeout(writeTimer) |
| 128 | +class ConsolePersistenceManager { |
| 129 | + private dataProvider: (() => PersistedConsoleData) | null = null |
| 130 | + private safetyTimer: ReturnType<typeof setTimeout> | null = null |
| 131 | + private activeExecutions = 0 |
| 132 | + |
| 133 | + /** |
| 134 | + * Binds the data provider function used to snapshot current state. |
| 135 | + * Called once during store initialization. |
| 136 | + */ |
| 137 | + bind(provider: () => PersistedConsoleData): void { |
| 138 | + this.dataProvider = provider |
129 | 139 | } |
130 | | - executeWrite() |
131 | | -} |
132 | 140 |
|
133 | | -/** |
134 | | - * Removes all persisted console data from IndexedDB. |
135 | | - */ |
136 | | -export async function clearPersistedConsoleData(): Promise<void> { |
137 | | - if (typeof window === 'undefined') return |
| 141 | + /** |
| 142 | + * Signals that a workflow execution has started. |
| 143 | + * Starts the long-execution safety-net timer if this is the first active execution. |
| 144 | + */ |
| 145 | + executionStarted(): void { |
| 146 | + this.activeExecutions++ |
| 147 | + if (this.activeExecutions === 1) { |
| 148 | + this.startSafetyTimer() |
| 149 | + } |
| 150 | + } |
138 | 151 |
|
139 | | - if (writeTimer !== null) { |
140 | | - clearTimeout(writeTimer) |
141 | | - writeTimer = null |
| 152 | + /** |
| 153 | + * Signals that a workflow execution has ended (success, error, or cancel). |
| 154 | + * Triggers an immediate persist and stops the safety timer if no executions remain. |
| 155 | + */ |
| 156 | + executionEnded(): void { |
| 157 | + this.activeExecutions = Math.max(0, this.activeExecutions - 1) |
| 158 | + this.persist() |
| 159 | + if (this.activeExecutions === 0) { |
| 160 | + this.stopSafetyTimer() |
| 161 | + } |
142 | 162 | } |
143 | | - pendingData = null |
144 | 163 |
|
145 | | - try { |
146 | | - await del(STORE_KEY) |
147 | | - } catch (error) { |
148 | | - logger.warn('IndexedDB delete failed', { error }) |
| 164 | + /** |
| 165 | + * Triggers an immediate persist. Used for explicit user actions |
| 166 | + * like clearing the console, and for page-hide durability. |
| 167 | + */ |
| 168 | + persist(): void { |
| 169 | + if (!this.dataProvider) return |
| 170 | + writeToIndexedDB(this.dataProvider()) |
| 171 | + } |
| 172 | + |
| 173 | + private startSafetyTimer(): void { |
| 174 | + this.stopSafetyTimer() |
| 175 | + this.safetyTimer = setInterval(() => { |
| 176 | + this.persist() |
| 177 | + }, LONG_EXECUTION_PERSIST_INTERVAL_MS) |
| 178 | + } |
| 179 | + |
| 180 | + private stopSafetyTimer(): void { |
| 181 | + if (this.safetyTimer !== null) { |
| 182 | + clearInterval(this.safetyTimer) |
| 183 | + this.safetyTimer = null |
| 184 | + } |
149 | 185 | } |
150 | 186 | } |
| 187 | + |
| 188 | +export const consolePersistence = new ConsolePersistenceManager() |
0 commit comments