blob: 4f5b22ddf0eb0faaf264e422ee705913b02efe3f [file]
import { constants } from 'node:fs';
import { open, type FileHandle } from 'node:fs/promises';
import { dirname } from 'node:path';
import { DurableStoreWriteError } from '@maka/core';
import { classifyJsonRecord } from './json-prefix.js';
import { syncDirectoryChain } from './stable-storage.js';
const REVERSE_SCAN_CHUNK_BYTES = 64 * 1024;
export interface AppendJsonlOptions {
durable?: boolean;
durabilityRoot?: string;
requireExistingRecord?: boolean;
}
export async function appendJsonl(
path: string,
payload: string,
options: AppendJsonlOptions = {},
): Promise<void> {
try {
await appendJsonlUnchecked(path, payload, options);
} catch (error) {
if (!options.durable || error instanceof DurableStoreWriteError) throw error;
throw new DurableStoreWriteError(
`Durable JSONL append did not reach stable storage: ${path}`,
error,
);
}
}
async function appendJsonlUnchecked(
path: string,
payload: string,
options: AppendJsonlOptions,
): Promise<void> {
if (payload.length === 0 || !payload.endsWith('\n')) {
throw new Error('JSONL append payload must end with a newline');
}
const flags =
constants.O_RDWR | constants.O_APPEND | (options.requireExistingRecord ? 0 : constants.O_CREAT);
const handle = await open(path, flags, 0o600);
try {
const size = (await handle.stat()).size;
if (size === 0 && options.requireExistingRecord) {
throw new Error('Cannot append to an empty JSONL document');
}
let separator = '';
if (size > 0 && !(await endsWithNewline(handle, size))) {
const tailStart = await findTrailingRecordStart(handle, size);
const tail = await readRange(handle, tailStart, size);
const classification = classifyJsonRecord(tail);
if (classification === 'complete') {
separator = '\n';
} else if (classification === 'incomplete-prefix') {
if (tailStart === 0 && options.requireExistingRecord) {
throw new Error('Cannot repair a truncated JSONL document header');
}
await handle.truncate(tailStart);
await handle.sync();
} else {
throw new Error('Cannot append after an invalid JSONL tail record');
}
}
await handle.appendFile(separator + payload, 'utf8');
if (options.durable) await handle.sync();
} finally {
await handle.close();
}
if (options.durable) {
if (!options.durabilityRoot) {
throw new Error('Durable JSONL append requires a durability root');
}
await syncDirectoryChain(dirname(path), options.durabilityRoot);
}
}
async function endsWithNewline(handle: FileHandle, size: number): Promise<boolean> {
const byte = Buffer.allocUnsafe(1);
await readFully(handle, byte, size - 1);
return byte[0] === 0x0a;
}
async function findTrailingRecordStart(handle: FileHandle, size: number): Promise<number> {
let end = size;
while (end > 0) {
const start = Math.max(0, end - REVERSE_SCAN_CHUNK_BYTES);
const chunk = Buffer.allocUnsafe(end - start);
await readFully(handle, chunk, start);
const newline = chunk.lastIndexOf(0x0a);
if (newline >= 0) return start + newline + 1;
end = start;
}
return 0;
}
async function readRange(handle: FileHandle, start: number, end: number): Promise<string> {
const bytes = Buffer.allocUnsafe(end - start);
await readFully(handle, bytes, start);
return bytes.toString('utf8');
}
async function readFully(handle: FileHandle, buffer: Buffer, position: number): Promise<void> {
let offset = 0;
while (offset < buffer.length) {
const { bytesRead } = await handle.read(
buffer,
offset,
buffer.length - offset,
position + offset,
);
if (bytesRead === 0) throw new Error('Unexpected end of JSONL document');
offset += bytesRead;
}
}