| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import fs from 'node:fs'; |
| import { crc32 } from './crc32.ts'; |
|
|
| export const MAGIC = Buffer.from([0x4d, 0x44]); |
| export const TYPE_SET = 1; |
| export const TYPE_DEL = 2; |
| export const TYPE_BATCH = 3; |
| export const HEADER_SIZE = 22; |
| export const CRC_SIZE = 4; |
| export const MAX_KEY_LEN = 0xffff; |
| export const MAX_VAL_LEN = 0xffffffff; |
|
|
| |
| export interface Frame { |
| type: number; |
| key: Buffer; |
| value: Buffer; |
| meta: Buffer | null; |
| expireAt: number; |
| } |
|
|
| export interface EncodeFrameInput { |
| type: number; |
| key: Buffer; |
| value?: Buffer | null; |
| meta?: Buffer | null; |
| expireAt?: number | bigint; |
| } |
|
|
| |
| export interface BatchOp { |
| type: number; |
| key: Buffer; |
| value: Buffer | null; |
| meta: Buffer | null; |
| expireAt: number; |
| } |
|
|
| export interface ParseResult { |
| frames: Frame[]; |
| corruptRanges: [number, number][]; |
| eofOffset: number; |
| } |
|
|
| |
| |
| export interface FrameRef { |
| type: number; |
| key: Buffer; |
| meta: Buffer | null; |
| expireAt: number; |
| frameOff: number; |
| valueOff: number; |
| valLen: number; |
| frameLen: number; |
| } |
|
|
| export interface ScanFrameRefsResult { |
| frames: FrameRef[]; |
| corruptRanges: [number, number][]; |
| eofOffset: number; |
| } |
|
|
| |
| export interface BatchOpRef { |
| type: number; |
| key: Buffer; |
| meta: Buffer | null; |
| expireAt: number; |
| valueOff: number; |
| valLen: number; |
| } |
|
|
| export class CorruptFrameError extends Error { |
| readonly offset: number; |
| constructor(message: string, offset: number) { |
| super(message); |
| this.name = 'CorruptFrameError'; |
| this.offset = offset; |
| } |
| } |
|
|
| const EMPTY: Buffer = Buffer.alloc(0); |
|
|
| |
| |
| |
| export function encodeFrame({ |
| type, |
| key, |
| value = null, |
| meta = null, |
| expireAt = 0, |
| }: EncodeFrameInput): Buffer { |
| if (!Buffer.isBuffer(key)) throw new TypeError('key must be a Buffer'); |
| if (key.length > MAX_KEY_LEN) throw new RangeError('key too large'); |
| const val: Buffer = value ?? EMPTY; |
| const met: Buffer = meta ?? EMPTY; |
| if (type === TYPE_SET && !Buffer.isBuffer(val)) throw new TypeError('value must be a Buffer for SET'); |
| if (!Buffer.isBuffer(met)) throw new TypeError('meta must be a Buffer'); |
| if (val.length > MAX_VAL_LEN) throw new RangeError('value too large'); |
| if (met.length > MAX_VAL_LEN) throw new RangeError('meta too large'); |
|
|
| const frame = Buffer.allocUnsafe(HEADER_SIZE + key.length + val.length + met.length + CRC_SIZE); |
|
|
| let o = 0; |
| MAGIC.copy(frame, o); o += 2; |
| frame.writeUInt8(type, o); o += 1; |
| frame.writeUInt8(0, o); o += 1; |
| frame.writeUInt16LE(key.length, o); o += 2; |
| frame.writeUInt32LE(val.length, o); o += 4; |
| frame.writeUInt32LE(met.length, o); o += 4; |
| frame.writeBigInt64LE(BigInt(expireAt ?? 0), o); o += 8; |
| key.copy(frame, o); o += key.length; |
| val.copy(frame, o); o += val.length; |
| met.copy(frame, o); o += met.length; |
|
|
| |
| const c = crc32(frame.subarray(2, o)); |
| frame.writeUInt32LE(c, o); |
| return frame; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const SUB_HEADER = 1 + 2 + 4 + 4 + 8; |
|
|
| export function encodeBatchOps(ops: BatchOp[]): Buffer { |
| let total = 2; |
| for (const op of ops) { |
| |
| |
| if (op.type !== TYPE_SET && op.type !== TYPE_DEL) { |
| throw new RangeError(`batch op type must be SET or DEL, got ${op.type}`); |
| } |
| total += SUB_HEADER + op.key.length + (op.value ? op.value.length : 0) + (op.meta ? op.meta.length : 0); |
| } |
| const body = Buffer.allocUnsafe(total); |
| let o = 0; |
| body.writeUInt16LE(ops.length, o); o += 2; |
| for (const op of ops) { |
| const key = op.key; |
| const val: Buffer = op.value ?? EMPTY; |
| const met: Buffer = op.meta ?? EMPTY; |
| body.writeUInt8(op.type, o); o += 1; |
| body.writeUInt16LE(key.length, o); o += 2; |
| body.writeUInt32LE(val.length, o); o += 4; |
| body.writeUInt32LE(met.length, o); o += 4; |
| body.writeBigInt64LE(BigInt(op.expireAt ?? 0), o); o += 8; |
| key.copy(body, o); o += key.length; |
| val.copy(body, o); o += val.length; |
| met.copy(body, o); o += met.length; |
| } |
| return body; |
| } |
|
|
| export function decodeBatchOps(body: Buffer): BatchOp[] { |
| const ops: BatchOp[] = []; |
| let o = 0; |
| if (body.length < 2) throw new RangeError('batch body truncated: op count'); |
| const count = body.readUInt16LE(o); o += 2; |
| for (let i = 0; i < count; i++) { |
| if (o + SUB_HEADER > body.length) throw new RangeError('batch op header truncated'); |
| const type = body.readUInt8(o); o += 1; |
| if (type !== TYPE_SET && type !== TYPE_DEL) throw new RangeError(`batch op has unknown type ${type}`); |
| const keyLen = body.readUInt16LE(o); o += 2; |
| const valLen = body.readUInt32LE(o); o += 4; |
| const metaLen = body.readUInt32LE(o); o += 4; |
| const expireAt = Number(body.readBigInt64LE(o)); o += 8; |
| if (o + keyLen + valLen + metaLen > body.length) throw new RangeError('batch op payload truncated'); |
| const key = Buffer.from(body.subarray(o, o + keyLen)); o += keyLen; |
| const value = Buffer.from(body.subarray(o, o + valLen)); o += valLen; |
| const meta = metaLen ? Buffer.from(body.subarray(o, o + metaLen)) : null; o += metaLen; |
| ops.push({ type, key, value, meta, expireAt }); |
| } |
| |
| |
| if (o !== body.length) throw new RangeError(`batch body has ${body.length - o} trailing byte(s)`); |
| return ops; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export class FrameParser { |
| private pending: Buffer = EMPTY; |
| private offset = 0; |
|
|
| *feed(chunk: Buffer): Generator<Frame> { |
| let buf: Buffer = this.pending.length ? Buffer.concat([this.pending, chunk]) : chunk; |
| let pos = 0; |
|
|
| while (true) { |
| if (buf.length - pos < HEADER_SIZE) break; |
|
|
| if (buf[pos] !== MAGIC[0] || buf[pos + 1] !== MAGIC[1]) { |
| const next = buf.indexOf(MAGIC, pos + 1); |
| if (next === -1) throw new CorruptFrameError('magic not found', this.offset + pos); |
| pos = next; |
| continue; |
| } |
|
|
| const type = buf.readUInt8(pos + 2); |
| const keyLen = buf.readUInt16LE(pos + 4); |
| const valLen = buf.readUInt32LE(pos + 6); |
| const metaLen = buf.readUInt32LE(pos + 10); |
| const frameLen = HEADER_SIZE + keyLen + valLen + metaLen + CRC_SIZE; |
|
|
| if (buf.length - pos < frameLen) break; |
|
|
| const storedCrc = buf.readUInt32LE(pos + frameLen - CRC_SIZE); |
| const computedCrc = crc32(buf.subarray(pos + 2, pos + frameLen - CRC_SIZE)); |
| if (storedCrc !== computedCrc) { |
| throw new CorruptFrameError(`crc mismatch at offset ${this.offset + pos}`, this.offset + pos); |
| } |
|
|
| const expireAt = Number(buf.readBigInt64LE(pos + 14)); |
| const keyStart = pos + HEADER_SIZE; |
| const key = buf.subarray(keyStart, keyStart + keyLen); |
| const value = buf.subarray(keyStart + keyLen, keyStart + keyLen + valLen); |
| const metaStart = keyStart + keyLen + valLen; |
| const meta = metaLen ? buf.subarray(metaStart, metaStart + metaLen) : null; |
|
|
| yield { |
| type, |
| key: Buffer.from(key), |
| value: Buffer.from(value), |
| meta: meta ? Buffer.from(meta) : null, |
| expireAt, |
| }; |
|
|
| pos += frameLen; |
| this.offset += frameLen; |
| } |
|
|
| this.pending = pos < buf.length ? Buffer.from(buf.subarray(pos)) : EMPTY; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| finish(): number { |
| if (this.pending.length > 0) { |
| const off = this.offset; |
| const n = this.pending.length; |
| this.pending = EMPTY; |
| throw new CorruptFrameError(`torn tail: ${n} trailing byte(s)`, off); |
| } |
| return this.offset; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| function readFrameAt(buf: Buffer, pos: number): { frame: Frame; frameLen: number } | null { |
| if (buf.length - pos < HEADER_SIZE) return null; |
| if (buf[pos] !== MAGIC[0] || buf[pos + 1] !== MAGIC[1]) return null; |
| const keyLen = buf.readUInt16LE(pos + 4); |
| const valLen = buf.readUInt32LE(pos + 6); |
| const metaLen = buf.readUInt32LE(pos + 10); |
| if (keyLen > MAX_KEY_LEN) return null; |
| const frameLen = HEADER_SIZE + keyLen + valLen + metaLen + CRC_SIZE; |
| if (frameLen < HEADER_SIZE + CRC_SIZE) return null; |
| if (buf.length - pos < frameLen) return null; |
| const stored = buf.readUInt32LE(pos + frameLen - CRC_SIZE); |
| const computed = crc32(buf.subarray(pos + 2, pos + frameLen - CRC_SIZE)); |
| if (stored !== computed) return null; |
|
|
| const expireAt = Number(buf.readBigInt64LE(pos + 14)); |
| const keyStart = pos + HEADER_SIZE; |
| const key = buf.subarray(keyStart, keyStart + keyLen); |
| const value = buf.subarray(keyStart + keyLen, keyStart + keyLen + valLen); |
| const metaStart = keyStart + keyLen + valLen; |
| const meta = metaLen ? buf.subarray(metaStart, metaStart + metaLen) : null; |
| return { |
| frame: { |
| type: buf.readUInt8(pos + 2), |
| key: Buffer.from(key), |
| value: Buffer.from(value), |
| meta: meta ? Buffer.from(meta) : null, |
| expireAt, |
| }, |
| frameLen, |
| }; |
| } |
|
|
| const CRC_CHUNK = 1 << 20; |
| const MAGIC_SCAN_CHUNK = 1 << 20; |
|
|
| |
| |
| |
| |
| |
| |
| export const DEFAULT_RESYNC_CANDIDATE_BUDGET = 65536; |
|
|
| function readExactSync(fd: number, buf: Buffer, pos: number): void { |
| let got = 0; |
| while (got < buf.length) { |
| const r = fs.readSync(fd, buf, got, buf.length - got, pos + got); |
| if (r === 0) throw new Error('codec: short read past EOF'); |
| got += r; |
| } |
| } |
|
|
| function readFrameRefAt(fd: number, pos: number, size: number): FrameRef | null { |
| if (size - pos < HEADER_SIZE) return null; |
| const header = Buffer.allocUnsafe(HEADER_SIZE); |
| readExactSync(fd, header, pos); |
| if (header[0] !== MAGIC[0] || header[1] !== MAGIC[1]) return null; |
|
|
| const type = header.readUInt8(2); |
| const keyLen = header.readUInt16LE(4); |
| const valLen = header.readUInt32LE(6); |
| const metaLen = header.readUInt32LE(10); |
| if (keyLen > MAX_KEY_LEN) return null; |
| const frameLen = HEADER_SIZE + keyLen + valLen + metaLen + CRC_SIZE; |
| if (frameLen < HEADER_SIZE + CRC_SIZE) return null; |
| if (size - pos < frameLen) return null; |
|
|
| let crc = 0; |
| let crcPos = pos + 2; |
| let crcLeft = frameLen - CRC_SIZE - 2; |
| while (crcLeft > 0) { |
| const len = Math.min(CRC_CHUNK, crcLeft); |
| const buf = Buffer.allocUnsafe(len); |
| readExactSync(fd, buf, crcPos); |
| crc = crc32(buf, crc); |
| crcPos += len; |
| crcLeft -= len; |
| } |
| const storedCrcBuf = Buffer.allocUnsafe(CRC_SIZE); |
| readExactSync(fd, storedCrcBuf, pos + frameLen - CRC_SIZE); |
| if (storedCrcBuf.readUInt32LE(0) !== crc) return null; |
|
|
| const keyStart = pos + HEADER_SIZE; |
| const valueOff = keyStart + keyLen; |
| const metaStart = valueOff + valLen; |
| const key = Buffer.allocUnsafe(keyLen); |
| if (keyLen) readExactSync(fd, key, keyStart); |
| let meta: Buffer | null = null; |
| if (metaLen) { |
| meta = Buffer.allocUnsafe(metaLen); |
| readExactSync(fd, meta, metaStart); |
| } |
|
|
| return { |
| type, |
| key, |
| meta, |
| expireAt: Number(header.readBigInt64LE(14)), |
| frameOff: pos, |
| valueOff, |
| valLen, |
| frameLen, |
| }; |
| } |
|
|
| function findMagicSync(fd: number, start: number, size: number): number { |
| const buf = Buffer.allocUnsafe(MAGIC_SCAN_CHUNK); |
| let pos = start; |
| while (pos < size) { |
| const len = Math.min(MAGIC_SCAN_CHUNK, size - pos); |
| const n = fs.readSync(fd, buf, 0, len, pos); |
| if (n === 0) return -1; |
| const idx = buf.subarray(0, n).indexOf(MAGIC); |
| if (idx >= 0) return pos + idx; |
| if (n < MAGIC.length) break; |
| pos += n - (MAGIC.length - 1); |
| } |
| return -1; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export function scanFrameRefsFd( |
| fd: number, |
| { |
| onCorrupt = 'resync', |
| startOffset = 0, |
| maxResyncCandidates = DEFAULT_RESYNC_CANDIDATE_BUDGET, |
| }: { onCorrupt?: 'resync' | 'strict'; startOffset?: number; maxResyncCandidates?: number } = {}, |
| ): ScanFrameRefsResult { |
| const size = fs.fstatSync(fd).size; |
| const frames: FrameRef[] = []; |
| const corruptRanges: [number, number][] = []; |
| let pos = startOffset; |
| let resyncCandidates = 0; |
|
|
| while (pos < size) { |
| const r = readFrameRefAt(fd, pos, size); |
| if (r) { |
| frames.push(r); |
| pos += r.frameLen; |
| continue; |
| } |
|
|
| if (onCorrupt === 'strict') { |
| corruptRanges.push([pos, size]); |
| break; |
| } |
|
|
| const badStart = pos; |
| let resume = -1; |
| let scan = pos + 1; |
| while (scan < size - 1) { |
| scan = findMagicSync(fd, scan, size); |
| if (scan === -1) break; |
| if (resyncCandidates++ >= maxResyncCandidates) break; |
| if (readFrameRefAt(fd, scan, size)) { |
| resume = scan; |
| break; |
| } |
| scan++; |
| } |
| corruptRanges.push([badStart, resume === -1 ? size : resume]); |
| if (resume === -1) break; |
| pos = resume; |
| } |
|
|
| return { frames, corruptRanges, eofOffset: pos }; |
| } |
|
|
| |
| export function scanFrameRefsFile( |
| filePath: string, |
| opts: { onCorrupt?: 'resync' | 'strict' } = {}, |
| ): ScanFrameRefsResult { |
| const fd = fs.openSync(filePath, 'r'); |
| try { |
| return scanFrameRefsFd(fd, opts); |
| } finally { |
| fs.closeSync(fd); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const ASYNC_SCAN_WINDOW = 1 << 22; |
| const SCAN_YIELD_BYTES = 1 << 23; |
|
|
| const yieldToLoop = (): Promise<void> => new Promise((r) => setImmediate(r)); |
|
|
| function scanAbortError(): Error { |
| const err = new Error('frame scan aborted'); |
| err.name = 'AbortError'; |
| return err; |
| } |
|
|
| |
| |
| function readAt(fd: number, buf: Buffer, bufOff: number, len: number, pos: number): Promise<number> { |
| return new Promise((resolve, reject) => { |
| fs.read(fd, buf, bufOff, len, pos, (err, bytesRead) => (err ? reject(err) : resolve(bytesRead))); |
| }); |
| } |
|
|
| async function readExactAsync(fd: number, buf: Buffer, pos: number): Promise<void> { |
| let got = 0; |
| while (got < buf.length) { |
| const bytesRead = await readAt(fd, buf, got, buf.length - got, pos + got); |
| if (bytesRead === 0) throw new Error('codec: short read past EOF'); |
| got += bytesRead; |
| } |
| } |
|
|
| |
| |
| async function readFrameRefAtAsync(fd: number, pos: number, size: number): Promise<FrameRef | null> { |
| if (size - pos < HEADER_SIZE) return null; |
| const header = Buffer.allocUnsafe(HEADER_SIZE); |
| await readExactAsync(fd, header, pos); |
| if (header[0] !== MAGIC[0] || header[1] !== MAGIC[1]) return null; |
|
|
| const type = header.readUInt8(2); |
| const keyLen = header.readUInt16LE(4); |
| const valLen = header.readUInt32LE(6); |
| const metaLen = header.readUInt32LE(10); |
| if (keyLen > MAX_KEY_LEN) return null; |
| const frameLen = HEADER_SIZE + keyLen + valLen + metaLen + CRC_SIZE; |
| if (frameLen < HEADER_SIZE + CRC_SIZE) return null; |
| if (size - pos < frameLen) return null; |
|
|
| let crc = 0; |
| let crcPos = pos + 2; |
| let crcLeft = frameLen - CRC_SIZE - 2; |
| while (crcLeft > 0) { |
| const len = Math.min(CRC_CHUNK, crcLeft); |
| const buf = Buffer.allocUnsafe(len); |
| await readExactAsync(fd, buf, crcPos); |
| crc = crc32(buf, crc); |
| crcPos += len; |
| crcLeft -= len; |
| } |
| const storedCrcBuf = Buffer.allocUnsafe(CRC_SIZE); |
| await readExactAsync(fd, storedCrcBuf, pos + frameLen - CRC_SIZE); |
| if (storedCrcBuf.readUInt32LE(0) !== crc) return null; |
|
|
| const keyStart = pos + HEADER_SIZE; |
| const valueOff = keyStart + keyLen; |
| const metaStart = valueOff + valLen; |
| const key = Buffer.allocUnsafe(keyLen); |
| if (keyLen) await readExactAsync(fd, key, keyStart); |
| let meta: Buffer | null = null; |
| if (metaLen) { |
| meta = Buffer.allocUnsafe(metaLen); |
| await readExactAsync(fd, meta, metaStart); |
| } |
|
|
| return { |
| type, |
| key, |
| meta, |
| expireAt: Number(header.readBigInt64LE(14)), |
| frameOff: pos, |
| valueOff, |
| valLen, |
| frameLen, |
| }; |
| } |
|
|
| |
| |
| |
| |
| |
| function parseFrameRefInWindow( |
| win: Buffer, |
| winStart: number, |
| winLen: number, |
| pos: number, |
| size: number, |
| ): FrameRef | null | 'window' { |
| const avail = winStart + winLen - pos; |
| if (avail < HEADER_SIZE) return null; |
| if (size - pos < HEADER_SIZE) return null; |
| const o = pos - winStart; |
| if (win[o] !== MAGIC[0] || win[o + 1] !== MAGIC[1]) return null; |
| const type = win.readUInt8(o + 2); |
| const keyLen = win.readUInt16LE(o + 4); |
| const valLen = win.readUInt32LE(o + 6); |
| const metaLen = win.readUInt32LE(o + 10); |
| if (keyLen > MAX_KEY_LEN) return null; |
| const frameLen = HEADER_SIZE + keyLen + valLen + metaLen + CRC_SIZE; |
| if (frameLen < HEADER_SIZE + CRC_SIZE) return null; |
| if (size - pos < frameLen) return null; |
| if (avail < frameLen) return 'window'; |
|
|
| |
| |
| let crc = 0; |
| let crcPos = o + 2; |
| let crcLeft = frameLen - CRC_SIZE - 2; |
| while (crcLeft > 0) { |
| const len = Math.min(CRC_CHUNK, crcLeft); |
| crc = crc32(win.subarray(crcPos, crcPos + len), crc); |
| crcPos += len; |
| crcLeft -= len; |
| } |
| if (win.readUInt32LE(o + frameLen - CRC_SIZE) !== crc) return null; |
|
|
| const keyStart = o + HEADER_SIZE; |
| const valueOff = pos + HEADER_SIZE + keyLen; |
| const metaStart = keyStart + keyLen + valLen; |
| const key = Buffer.from(win.subarray(keyStart, keyStart + keyLen)); |
| const meta = metaLen ? Buffer.from(win.subarray(metaStart, metaStart + metaLen)) : null; |
| return { type, key, meta, expireAt: Number(win.readBigInt64LE(o + 14)), frameOff: pos, valueOff, valLen, frameLen }; |
| } |
|
|
| |
| |
| |
| |
| |
| export async function scanFrameRefsFdAsync( |
| fd: number, |
| { |
| onCorrupt = 'resync', |
| startOffset = 0, |
| endOffset, |
| signal, |
| maxResyncCandidates = DEFAULT_RESYNC_CANDIDATE_BUDGET, |
| }: { |
| onCorrupt?: 'resync' | 'strict'; |
| startOffset?: number; |
| /** Scan only [startOffset, endOffset) of the file (the stage-6 worker |
| * pins its source to a WAL checkpoint; a live writer may have appended |
| * past it). Defaults to the file's current size. */ |
| endOffset?: number; |
| signal?: AbortSignal; |
| maxResyncCandidates?: number; |
| } = {}, |
| ): Promise<ScanFrameRefsResult> { |
| const size = Math.min(fs.fstatSync(fd).size, endOffset ?? Number.POSITIVE_INFINITY); |
| const frames: FrameRef[] = []; |
| const corruptRanges: [number, number][] = []; |
| const win = Buffer.allocUnsafe(ASYNC_SCAN_WINDOW); |
| let winStart = startOffset; // absolute offset of win[0] |
| let winLen = 0; // valid bytes in the window |
| let pos = startOffset; |
| let sinceYield = 0; |
| let resyncCandidates = 0; |
| |
| const throwIfAborted = (): void => { |
| if (signal?.aborted) throw scanAbortError(); |
| }; |
| |
| /** Read the window covering `pos`: the leftover suffix is compacted when |
| * it overlaps, otherwise the window restarts at pos. */ |
| const fillWindow = async (at: number): Promise<void> => { |
| const end = winStart + winLen; |
| if (at >= winStart && at < end) { |
| const keep = end - at; |
| win.copyWithin(0, at - winStart, at - winStart + keep); |
| winStart = at; |
| winLen = keep; |
| } else { |
| winStart = at; |
| winLen = 0; |
| } |
| while (winLen < win.length && winStart + winLen < size) { |
| const bytesRead = await readAt(fd, win, winLen, Math.min(win.length - winLen, size - winStart - winLen), winStart + winLen); |
| if (bytesRead === 0) break; |
| winLen += bytesRead; |
| } |
| }; |
| |
| /** Parse the frame at `pos`, refilling the window or falling back to |
| * chunked positioned reads for a frame larger than the window. */ |
| const frameAt = async (at: number): Promise<FrameRef | null> => { |
| if (at < winStart || at + HEADER_SIZE > winStart + winLen) await fillWindow(at); |
| let r = parseFrameRefInWindow(win, winStart, winLen, at, size); |
| if (r !== 'window') return r; |
| // The frame spans past the window: refilling can only help while the |
| // whole frame still fits one window; larger frames take the positioned |
| // path so their value bytes never sit in RAM. |
| if (at - winStart > 0) { |
| await fillWindow(at); |
| r = parseFrameRefInWindow(win, winStart, winLen, at, size); |
| if (r !== 'window') return r; |
| } |
| return readFrameRefAtAsync(fd, at, size); |
| }; |
| |
| const tick = async (advanced: number): Promise<void> => { |
| sinceYield += advanced; |
| if (sinceYield >= SCAN_YIELD_BYTES) { |
| sinceYield = 0; |
| throwIfAborted(); |
| await yieldToLoop(); |
| } |
| }; |
| |
| throwIfAborted(); |
| while (pos < size) { |
| const r = await frameAt(pos); |
| if (r) { |
| frames.push(r); |
| pos += r.frameLen; |
| await tick(r.frameLen); |
| continue; |
| } |
| |
| if (onCorrupt === 'strict') { |
| corruptRanges.push([pos, size]); |
| break; |
| } |
| |
| const badStart = pos; |
| let resume = -1; |
| let scan = pos + 1; |
| while (scan < size - 1) { |
| // Find the next magic from the current window contents (refilling as |
| // the scan position moves forward), then validate the candidate. |
| if (scan < winStart || scan >= winStart + winLen) await fillWindow(scan); |
| const idx = win.indexOf(MAGIC, scan - winStart); |
| const found = idx === -1 ? -1 : winStart + idx; |
| if (found === -1) { |
| // No magic in the remaining window: if the window reached EOF the |
| // resync is over, otherwise jump straight to the next window (the |
| // last MAGIC.length - 1 bytes may hold a partial magic). |
| const end = winStart + winLen; |
| if (end >= size) { |
| scan = size; |
| break; |
| } |
| scan = Math.max(end - (MAGIC.length - 1), scan + 1); |
| await tick(ASYNC_SCAN_WINDOW); |
| continue; |
| } |
| scan = found; |
| if (scan >= size - 1) break; |
| if (resyncCandidates++ >= maxResyncCandidates) { |
| scan = size; |
| break; |
| } |
| const candidate = await frameAt(scan); |
| if (candidate) { |
| resume = scan; |
| break; |
| } |
| scan++; |
| } |
| corruptRanges.push([badStart, resume === -1 ? size : resume]); |
| if (resume === -1) break; |
| pos = resume; |
| } |
| throwIfAborted(); |
| return { frames, corruptRanges, eofOffset: pos }; |
| } |
| |
| /** Scan BATCH body op refs without copying op values. `bodyOff` is the absolute |
| * file offset where the BATCH body (the outer frame's value) starts. |
| * Strictly validated (review #9): sub-op types must be SET/DEL, every op must |
| * stay in bounds, and the body must end exactly after its last op — a |
| * violation throws, so the caller (frameToOps) skips the whole batch instead |
| * of half-applying it. */ |
| export function scanBatchOpRefs(body: Buffer, bodyOff: number): BatchOpRef[] { |
| const ops: BatchOpRef[] = []; |
| let o = 0; |
| if (body.length < 2) throw new RangeError('batch body truncated: op count'); |
| const count = body.readUInt16LE(o); |
| o += 2; |
| for (let i = 0; i < count; i++) { |
| if (o + SUB_HEADER > body.length) throw new RangeError('batch op header truncated'); |
| const type = body.readUInt8(o); |
| o += 1; |
| if (type !== TYPE_SET && type !== TYPE_DEL) throw new RangeError(`batch op has unknown type ${type}`); |
| const keyLen = body.readUInt16LE(o); |
| o += 2; |
| const valLen = body.readUInt32LE(o); |
| o += 4; |
| const metaLen = body.readUInt32LE(o); |
| o += 4; |
| const expireAt = Number(body.readBigInt64LE(o)); |
| o += 8; |
| if (o + keyLen + valLen + metaLen > body.length) throw new RangeError('batch op payload truncated'); |
| const key = Buffer.from(body.subarray(o, o + keyLen)); |
| const valueOff = bodyOff + o + keyLen; |
| o += keyLen + valLen; |
| const meta = metaLen ? Buffer.from(body.subarray(o, o + metaLen)) : null; |
| o += metaLen; |
| ops.push({ type, key, valueOff, valLen, meta, expireAt }); |
| } |
| if (o !== body.length) throw new RangeError(`batch body has ${body.length - o} trailing byte(s)`); |
| return ops; |
| } |
| |
| /** |
| * Parse a complete buffer into frames, with configurable corruption handling. |
| * |
| * - onCorrupt = 'resync' (default): a bad/incomplete frame is skipped and the |
| * parser resynchronizes to the next valid frame. Only the corrupted bytes are |
| * lost; everything after the next valid frame is recovered. |
| * - onCorrupt = 'strict': stop at the first bad frame and treat the entire tail |
| * as lost. Frames before the first bad frame are kept. |
| */ |
| export function parseBuffer( |
| buf: Buffer, |
| { onCorrupt = 'resync' }: { onCorrupt?: 'resync' | 'strict' } = {}, |
| ): ParseResult { |
| const frames: Frame[] = []; |
| const corruptRanges: [number, number][] = []; |
| let pos = 0; |
| |
| while (pos < buf.length) { |
| const r = readFrameAt(buf, pos); |
| if (r) { |
| frames.push(r.frame); |
| pos += r.frameLen; |
| continue; |
| } |
| |
| if (onCorrupt === 'strict') { |
| corruptRanges.push([pos, buf.length]); |
| break; |
| } |
| |
| // Resync: scan forward for the next frame that validates. |
| const badStart = pos; |
| let resume = -1; |
| let scan = pos + 1; |
| while (scan < buf.length - 1) { |
| scan = buf.indexOf(MAGIC, scan); |
| if (scan === -1) break; |
| if (readFrameAt(buf, scan)) { |
| resume = scan; |
| break; |
| } |
| scan++; |
| } |
| corruptRanges.push([badStart, resume === -1 ? buf.length : resume]); |
| if (resume === -1) break; |
| pos = resume; |
| } |
| |
| return { frames, corruptRanges, eofOffset: pos }; |
| } |
| |