* Remove a message from the transcript by UUID. * Used for tombstoning orphaned messages from failed streaming attempts. * * The target is almost always the most recently appended entry, so we * read only the tail, locate the line, and splice it out with a * positional write + truncat
(targetUuid: UUID)
| 950 | * positional write + truncate instead of rewriting the whole file. |
| 951 | */ |
| 952 | async removeMessageByUuid(targetUuid: UUID): Promise<void> { |
| 953 | return this.trackWrite(async () => { |
| 954 | if (this.sessionFile === null) return |
| 955 | try { |
| 956 | let fileSize = 0 |
| 957 | const fh = await fsOpen(this.sessionFile, 'r+') |
| 958 | try { |
| 959 | const { size } = await fh.stat() |
| 960 | fileSize = size |
| 961 | if (size === 0) return |
| 962 | |
| 963 | const chunkLen = Math.min(size, LITE_READ_BUF_SIZE) |
| 964 | const tailStart = size - chunkLen |
| 965 | const buf = Buffer.allocUnsafe(chunkLen) |
| 966 | const { bytesRead } = await fh.read(buf, 0, chunkLen, tailStart) |
| 967 | const tail = buf.subarray(0, bytesRead) |
| 968 | |
| 969 | // Entries are serialized via JSON.stringify (no key-value |
| 970 | // whitespace). Search for the full `"uuid":"..."` pattern, not |
| 971 | // just the bare UUID, so we do not match the same value sitting |
| 972 | // in `parentUuid` of a child entry. UUIDs are pure ASCII so a |
| 973 | // byte-level search is correct. |
| 974 | const needle = `"uuid":"${targetUuid}"` |
| 975 | const matchIdx = tail.lastIndexOf(needle) |
| 976 | |
| 977 | if (matchIdx >= 0) { |
| 978 | // 0x0a never appears inside a UTF-8 multi-byte sequence, so |
| 979 | // byte-scanning for line boundaries is safe even if the chunk |
| 980 | // starts mid-character. |
| 981 | const prevNl = tail.lastIndexOf(0x0a, matchIdx) |
| 982 | // If the preceding newline is outside our chunk and we did not |
| 983 | // read from the start of the file, the line is longer than the |
| 984 | // window - fall through to the slow path. |
| 985 | if (prevNl >= 0 || tailStart === 0) { |
| 986 | const lineStart = prevNl + 1 // 0 when prevNl === -1 |
| 987 | const nextNl = tail.indexOf(0x0a, matchIdx + needle.length) |
| 988 | const lineEnd = nextNl >= 0 ? nextNl + 1 : bytesRead |
| 989 | |
| 990 | const absLineStart = tailStart + lineStart |
| 991 | const afterLen = bytesRead - lineEnd |
| 992 | // Truncate first, then re-append the trailing lines. In the |
| 993 | // common case (target is the last entry) afterLen is 0 and |
| 994 | // this is a single ftruncate. |
| 995 | await fh.truncate(absLineStart) |
| 996 | if (afterLen > 0) { |
| 997 | await fh.write(tail, lineEnd, afterLen, absLineStart) |
| 998 | } |
| 999 | return |
| 1000 | } |
| 1001 | } |
| 1002 | } finally { |
| 1003 | await fh.close() |
| 1004 | } |
| 1005 | |
| 1006 | // Slow path: target was not in the last 64KB. Rare - requires many |
| 1007 | // large entries to have landed between the write and the tombstone. |
| 1008 | if (fileSize > MAX_TOMBSTONE_REWRITE_BYTES) { |
| 1009 | logForDebugging( |
no test coverage detected