* 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)
| 915 | * positional write + truncate instead of rewriting the whole file. |
| 916 | */ |
| 917 | async removeMessageByUuid(targetUuid: UUID): Promise<void> { |
| 918 | return this.trackWrite(async () => { |
| 919 | if (this.sessionFile === null) return |
| 920 | try { |
| 921 | let fileSize = 0 |
| 922 | const fh = await fsOpen(this.sessionFile, 'r+') |
| 923 | try { |
| 924 | const { size } = await fh.stat() |
| 925 | fileSize = size |
| 926 | if (size === 0) return |
| 927 | |
| 928 | const chunkLen = Math.min(size, LITE_READ_BUF_SIZE) |
| 929 | const tailStart = size - chunkLen |
| 930 | const buf = Buffer.allocUnsafe(chunkLen) |
| 931 | const { bytesRead } = await fh.read(buf, 0, chunkLen, tailStart) |
| 932 | const tail = buf.subarray(0, bytesRead) |
| 933 | |
| 934 | // Entries are serialized via JSON.stringify (no key-value |
| 935 | // whitespace). Search for the full `"uuid":"..."` pattern, not |
| 936 | // just the bare UUID, so we do not match the same value sitting |
| 937 | // in `parentUuid` of a child entry. UUIDs are pure ASCII so a |
| 938 | // byte-level search is correct. |
| 939 | const needle = `"uuid":"${targetUuid}"` |
| 940 | const matchIdx = tail.lastIndexOf(needle) |
| 941 | |
| 942 | if (matchIdx >= 0) { |
| 943 | // 0x0a never appears inside a UTF-8 multi-byte sequence, so |
| 944 | // byte-scanning for line boundaries is safe even if the chunk |
| 945 | // starts mid-character. |
| 946 | const prevNl = tail.lastIndexOf(0x0a, matchIdx) |
| 947 | // If the preceding newline is outside our chunk and we did not |
| 948 | // read from the start of the file, the line is longer than the |
| 949 | // window - fall through to the slow path. |
| 950 | if (prevNl >= 0 || tailStart === 0) { |
| 951 | const lineStart = prevNl + 1 // 0 when prevNl === -1 |
| 952 | const nextNl = tail.indexOf(0x0a, matchIdx + needle.length) |
| 953 | const lineEnd = nextNl >= 0 ? nextNl + 1 : bytesRead |
| 954 | |
| 955 | const absLineStart = tailStart + lineStart |
| 956 | const afterLen = bytesRead - lineEnd |
| 957 | // Truncate first, then re-append the trailing lines. In the |
| 958 | // common case (target is the last entry) afterLen is 0 and |
| 959 | // this is a single ftruncate. |
| 960 | await fh.truncate(absLineStart) |
| 961 | if (afterLen > 0) { |
| 962 | await fh.write(tail, lineEnd, afterLen, absLineStart) |
| 963 | } |
| 964 | return |
| 965 | } |
| 966 | } |
| 967 | } finally { |
| 968 | await fh.close() |
| 969 | } |
| 970 | |
| 971 | // Slow path: target was not in the last 64KB. Rare - requires many |
| 972 | // large entries to have landed between the write and the tombstone. |
| 973 | if (fileSize > MAX_TOMBSTONE_REWRITE_BYTES) { |
| 974 | logForDebugging( |
no test coverage detected