* Save a YJS document to disk. * Uses atomic write (temp file + rename) to prevent corruption. * Merges incoming data with existing disk state to never lose data. * Serializes saves per document to prevent race conditions.
(id: string, data: Uint8Array)
| 126 | * Serializes saves per document to prevent race conditions. |
| 127 | */ |
| 128 | async function handleSave(id: string, data: Uint8Array): Promise<void> { |
| 129 | // Chain this save after any pending save for the same document |
| 130 | const prevSave = saveQueues.get(id) ?? Promise.resolve() |
| 131 | |
| 132 | const currentSave = prevSave.then(async () => { |
| 133 | await ensureStorageDir() |
| 134 | |
| 135 | const filePath = getDocPath(id) |
| 136 | const tempPath = `${filePath}.tmp.${Date.now()}.${Math.random().toString(36).slice(2)}` |
| 137 | |
| 138 | let doc: Y.Doc | null = null |
| 139 | try { |
| 140 | // Load existing state if present |
| 141 | let existingData: Buffer | null = null |
| 142 | try { |
| 143 | existingData = await fs.readFile(filePath) |
| 144 | } catch (error) { |
| 145 | if ((error as NodeJS.ErrnoException).code !== "ENOENT") { |
| 146 | throw error |
| 147 | } |
| 148 | // File doesn't exist yet, that's fine |
| 149 | } |
| 150 | |
| 151 | // Fast path: if incoming data exactly matches existing, skip the save |
| 152 | if (existingData && existingData.length === data.length) { |
| 153 | let identical = true |
| 154 | for (let i = 0; i < data.length; i++) { |
| 155 | if (existingData[i] !== data[i]) { |
| 156 | identical = false |
| 157 | break |
| 158 | } |
| 159 | } |
| 160 | if (identical) { |
| 161 | logger.debug(`[YjsStorage] Skipped save (unchanged): ${id} (${data.length} bytes)`) |
| 162 | return |
| 163 | } |
| 164 | } |
| 165 | |
| 166 | // Merge with existing data to never lose updates |
| 167 | doc = new Y.Doc() |
| 168 | |
| 169 | if (existingData) { |
| 170 | Y.applyUpdate(doc, new Uint8Array(existingData)) |
| 171 | } |
| 172 | |
| 173 | // Apply incoming update (merges with existing via CRDT) |
| 174 | Y.applyUpdate(doc, data) |
| 175 | |
| 176 | // Encode merged state |
| 177 | const mergedState = Y.encodeStateAsUpdate(doc) |
| 178 | |
| 179 | // Atomic write: write to temp file, then rename |
| 180 | await fs.writeFile(tempPath, mergedState) |
| 181 | await fs.rename(tempPath, filePath) |
| 182 | |
| 183 | logger.debug(`[YjsStorage] Saved document: ${id} (${mergedState.length} bytes)`) |
| 184 | } catch (error) { |
| 185 | // Clean up temp file if it exists |
no test coverage detected