* 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)
| 869 | * positional write + truncate instead of rewriting the whole file. |
| 870 | */ |
| 871 | async removeMessageByUuid(targetUuid: UUID): Promise<void> { |
| 872 | return this.trackWrite(async () => { |
| 873 | if (this.sessionFile === null) return |
| 874 | try { |
| 875 | let fileSize = 0 |
| 876 | const fh = await fsOpen(this.sessionFile, 'r+') |
| 877 | try { |
| 878 | const { size } = await fh.stat() |
| 879 | fileSize = size |
| 880 | if (size === 0) return |
| 881 | |
| 882 | const chunkLen = Math.min(size, LITE_READ_BUF_SIZE) |
| 883 | const tailStart = size - chunkLen |
| 884 | const buf = Buffer.allocUnsafe(chunkLen) |
| 885 | const { bytesRead } = await fh.read(buf, 0, chunkLen, tailStart) |
| 886 | const tail = buf.subarray(0, bytesRead) |
| 887 | |
| 888 | // Entries are serialized via JSON.stringify (no key-value |
| 889 | // whitespace). Search for the full `"uuid":"..."` pattern, not |
| 890 | // just the bare UUID, so we do not match the same value sitting |
| 891 | // in `parentUuid` of a child entry. UUIDs are pure ASCII so a |
| 892 | // byte-level search is correct. |
| 893 | const needle = `"uuid":"${targetUuid}"` |
| 894 | const matchIdx = tail.lastIndexOf(needle) |
| 895 | |
| 896 | if (matchIdx >= 0) { |
| 897 | // 0x0a never appears inside a UTF-8 multi-byte sequence, so |
| 898 | // byte-scanning for line boundaries is safe even if the chunk |
| 899 | // starts mid-character. |
| 900 | const prevNl = tail.lastIndexOf(0x0a, matchIdx) |
| 901 | // If the preceding newline is outside our chunk and we did not |
| 902 | // read from the start of the file, the line is longer than the |
| 903 | // window - fall through to the slow path. |
| 904 | if (prevNl >= 0 || tailStart === 0) { |
| 905 | const lineStart = prevNl + 1 // 0 when prevNl === -1 |
| 906 | const nextNl = tail.indexOf(0x0a, matchIdx + needle.length) |
| 907 | const lineEnd = nextNl >= 0 ? nextNl + 1 : bytesRead |
| 908 | |
| 909 | const absLineStart = tailStart + lineStart |
| 910 | const afterLen = bytesRead - lineEnd |
| 911 | // Truncate first, then re-append the trailing lines. In the |
| 912 | // common case (target is the last entry) afterLen is 0 and |
| 913 | // this is a single ftruncate. |
| 914 | await fh.truncate(absLineStart) |
| 915 | if (afterLen > 0) { |
| 916 | await fh.write(tail, lineEnd, afterLen, absLineStart) |
| 917 | } |
| 918 | return |
| 919 | } |
| 920 | } |
| 921 | } finally { |
| 922 | await fh.close() |
| 923 | } |
| 924 | |
| 925 | // Slow path: target was not in the last 64KB. Rare - requires many |
| 926 | // large entries to have landed between the write and the tombstone. |
| 927 | if (fileSize > MAX_TOMBSTONE_REWRITE_BYTES) { |
| 928 | logForDebugging( |
no test coverage detected