(noteId: string, title: string, content: string, createdBy = "system", kind = "manual", summary: string | null = null)
| 262 | const existing = this.saveTimers.get(noteId); |
| 263 | if (existing) clearTimeout(existing); |
| 264 | this.saveTimers.set(noteId, setTimeout(() => { |
| 265 | this.saveTimers.delete(noteId); |
| 266 | try { |
| 267 | this.persistDocNow(noteId, doc); |
| 268 | } catch (e) { |
| 269 | console.error(`Failed to persist Yjs state for note ${noteId} (attempt ${attempt + 1}):`, e); |
| 270 | // Don't drop the only copy of the user's edits on a transient |
| 271 | // failure — re-arm with backoff. The in-memory doc is the sole |
| 272 | // holder of this content until a persist succeeds. |
| 273 | if (attempt < 5) this.scheduleDocPersist(noteId, doc, Math.min(2000 * 2 ** attempt, 30000), attempt + 1); |
| 274 | } |
| 275 | }, delayMs)); |
| 276 | } |
| 277 | |
| 278 | // Consecutive autosave-session versions inside this window are amended in |
| 279 | // place instead of appended, so tab-switch/idle churn doesn't flood history. |
| 280 | private readonly SESSION_AMEND_WINDOW_S = 15 * 60; |
| 281 | private readonly MAX_VERSIONS_PER_NOTE = 1000; |
| 282 | |
| 283 | private versionKey(noteId: string, versionId: string): string { |
| 284 | return `versions/${this.ctx.id.toString()}/${noteId}/${versionId}.json`; |
| 285 | } |
| 286 | |
| 287 | // --- Branch helpers --- |
| 288 | |
| 289 | private ensureDefaultBranch(noteId: string): { id: string; name: string; head_version_id: string | null } { |
| 290 | const existing = this.sql.exec( |
| 291 | "SELECT id, name, head_version_id FROM note_branches WHERE note_id = ? AND is_default = 1", noteId |
| 292 | ).toArray()[0] as any; |
| 293 | if (existing) return existing; |
| 294 | |
| 295 | const id = crypto.randomUUID(); |
| 296 | this.sql.exec( |
| 297 | "INSERT INTO note_branches (id, note_id, name, is_default) VALUES (?, ?, 'main', 1)", id, noteId |
| 298 | ); |
| 299 | this.sql.exec( |
| 300 | "UPDATE notes SET current_branch_id = ? WHERE id = ? AND current_branch_id IS NULL", id, noteId |
| 301 | ); |
| 302 | return { id, name: "main", head_version_id: null }; |
| 303 | } |
| 304 | |
| 305 | private getCurrentBranch(noteId: string): { id: string; name: string; head_version_id: string | null } { |
| 306 | const note = this.getNote(noteId); |
| 307 | if (note?.current_branch_id) { |
| 308 | const branch = this.sql.exec( |
| 309 | "SELECT id, name, head_version_id FROM note_branches WHERE id = ?", note.current_branch_id |
| 310 | ).toArray()[0] as any; |
| 311 | if (branch) return branch; |
| 312 | } |
| 313 | return this.ensureDefaultBranch(noteId); |
| 314 | } |
| 315 | |
| 316 | // --- Version creation (branch-aware) --- |
| 317 | |
| 318 | private async createVersion(noteId: string, title: string, content: string, createdBy = "system", kind = "manual", summary: string | null = null): Promise<string | null> { |
| 319 | const branch = this.getCurrentBranch(noteId); |
| 320 | const parentId = branch.head_version_id; |
| 321 | const hash = this.contentHash(content); |
no test coverage detected