summaryrefslogtreecommitdiffhomepage
path: root/packages/journal-sink/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/journal-sink/src')
-rw-r--r--packages/journal-sink/src/journal-sink.test.ts508
-rw-r--r--packages/journal-sink/src/journal-sink.ts262
2 files changed, 385 insertions, 385 deletions
diff --git a/packages/journal-sink/src/journal-sink.test.ts b/packages/journal-sink/src/journal-sink.test.ts
index a6531c7..36e4983 100644
--- a/packages/journal-sink/src/journal-sink.test.ts
+++ b/packages/journal-sink/src/journal-sink.test.ts
@@ -9,123 +9,123 @@ import { createJournalSink, serialize } from "./journal-sink.js";
// --- Fixtures: one per LogRecord variant ---
const logRecord: LogRecord = {
- kind: "log",
- level: "info",
- msg: "hello world",
- timestamp: 1700000000000,
- extensionId: "test-ext",
- conversationId: "conv-1",
- turnId: "turn-1",
- spanId: "span-1",
- attributes: { key: "value" },
+ kind: "log",
+ level: "info",
+ msg: "hello world",
+ timestamp: 1700000000000,
+ extensionId: "test-ext",
+ conversationId: "conv-1",
+ turnId: "turn-1",
+ spanId: "span-1",
+ attributes: { key: "value" },
};
const logRecordMinimal: LogRecord = {
- kind: "log",
- level: "debug",
- msg: "minimal",
- timestamp: 1700000000001,
- extensionId: "ext-min",
+ kind: "log",
+ level: "debug",
+ msg: "minimal",
+ timestamp: 1700000000001,
+ extensionId: "ext-min",
};
const spanOpenRecord: LogRecord = {
- kind: "span-open",
- spanId: "span-2",
- name: "provider.request",
- timestamp: 1700000000100,
- extensionId: "test-ext",
- conversationId: "conv-1",
- turnId: "turn-1",
- parentSpanId: "span-1",
- attributes: { model: "gpt-4" },
- links: [{ spanId: "span-0", turnId: "turn-0", reason: "caused-by" }],
- body: "verbatim request body",
+ kind: "span-open",
+ spanId: "span-2",
+ name: "provider.request",
+ timestamp: 1700000000100,
+ extensionId: "test-ext",
+ conversationId: "conv-1",
+ turnId: "turn-1",
+ parentSpanId: "span-1",
+ attributes: { model: "gpt-4" },
+ links: [{ spanId: "span-0", turnId: "turn-0", reason: "caused-by" }],
+ body: "verbatim request body",
};
const spanOpenRecordMinimal: LogRecord = {
- kind: "span-open",
- spanId: "span-3",
- name: "tool.call",
- timestamp: 1700000000200,
- extensionId: "ext-tool",
+ kind: "span-open",
+ spanId: "span-3",
+ name: "tool.call",
+ timestamp: 1700000000200,
+ extensionId: "ext-tool",
};
const spanCloseRecord: LogRecord = {
- kind: "span-close",
- spanId: "span-2",
- name: "provider.request",
- timestamp: 1700000000500,
- durationMs: 400,
- status: "ok",
- extensionId: "test-ext",
- conversationId: "conv-1",
- turnId: "turn-1",
- parentSpanId: "span-1",
- attributes: { cacheHit: true },
- links: [{ spanId: "span-0" }],
+ kind: "span-close",
+ spanId: "span-2",
+ name: "provider.request",
+ timestamp: 1700000000500,
+ durationMs: 400,
+ status: "ok",
+ extensionId: "test-ext",
+ conversationId: "conv-1",
+ turnId: "turn-1",
+ parentSpanId: "span-1",
+ attributes: { cacheHit: true },
+ links: [{ spanId: "span-0" }],
};
const spanCloseRecordError: LogRecord = {
- kind: "span-close",
- spanId: "span-4",
- name: "failing-step",
- timestamp: 1700000000600,
- durationMs: 100,
- status: "error",
- extensionId: "ext-err",
+ kind: "span-close",
+ spanId: "span-4",
+ name: "failing-step",
+ timestamp: 1700000000600,
+ durationMs: 100,
+ status: "error",
+ extensionId: "ext-err",
};
// --- Pure core: serialize ---
describe("serialize", () => {
- const allRecords = [
- { name: "log (full)", record: logRecord },
- { name: "log (minimal)", record: logRecordMinimal },
- { name: "span-open (full)", record: spanOpenRecord },
- { name: "span-open (minimal)", record: spanOpenRecordMinimal },
- { name: "span-close (full)", record: spanCloseRecord },
- { name: "span-close (error)", record: spanCloseRecordError },
- ];
-
- for (const { name, record } of allRecords) {
- it(`produces exactly one NDJSON line for ${name}`, () => {
- const line = serialize(record);
- expect(line.endsWith("\n")).toBe(true);
- expect(line.endsWith("\n\n")).toBe(false);
- const parsed = JSON.parse(line);
- expect(parsed).toEqual(record);
- });
-
- it(`round-trips ${name} through JSON.parse`, () => {
- const line = serialize(record);
- const roundTripped: LogRecord = JSON.parse(line);
- expect(roundTripped).toEqual(record);
- expect(roundTripped.kind).toBe(record.kind);
- });
- }
-
- it("preserves all LogRecord variant kinds", () => {
- expect(JSON.parse(serialize(logRecord)).kind).toBe("log");
- expect(JSON.parse(serialize(spanOpenRecord)).kind).toBe("span-open");
- expect(JSON.parse(serialize(spanCloseRecord)).kind).toBe("span-close");
- });
-
- it("preserves optional fields when present", () => {
- const parsed = JSON.parse(serialize(spanOpenRecord));
- expect(parsed.body).toBe("verbatim request body");
- expect(parsed.links).toEqual([{ spanId: "span-0", turnId: "turn-0", reason: "caused-by" }]);
- expect(parsed.attributes).toEqual({ model: "gpt-4" });
- });
-
- it("omits optional fields when absent (no undefined in output)", () => {
- const parsed = JSON.parse(serialize(logRecordMinimal));
- expect(parsed.conversationId).toBeUndefined();
- expect(parsed.turnId).toBeUndefined();
- expect(parsed.spanId).toBeUndefined();
- expect(parsed.attributes).toBeUndefined();
- expect(parsed.body).toBeUndefined();
- expect("conversationId" in parsed).toBe(false);
- });
+ const allRecords = [
+ { name: "log (full)", record: logRecord },
+ { name: "log (minimal)", record: logRecordMinimal },
+ { name: "span-open (full)", record: spanOpenRecord },
+ { name: "span-open (minimal)", record: spanOpenRecordMinimal },
+ { name: "span-close (full)", record: spanCloseRecord },
+ { name: "span-close (error)", record: spanCloseRecordError },
+ ];
+
+ for (const { name, record } of allRecords) {
+ it(`produces exactly one NDJSON line for ${name}`, () => {
+ const line = serialize(record);
+ expect(line.endsWith("\n")).toBe(true);
+ expect(line.endsWith("\n\n")).toBe(false);
+ const parsed = JSON.parse(line);
+ expect(parsed).toEqual(record);
+ });
+
+ it(`round-trips ${name} through JSON.parse`, () => {
+ const line = serialize(record);
+ const roundTripped: LogRecord = JSON.parse(line);
+ expect(roundTripped).toEqual(record);
+ expect(roundTripped.kind).toBe(record.kind);
+ });
+ }
+
+ it("preserves all LogRecord variant kinds", () => {
+ expect(JSON.parse(serialize(logRecord)).kind).toBe("log");
+ expect(JSON.parse(serialize(spanOpenRecord)).kind).toBe("span-open");
+ expect(JSON.parse(serialize(spanCloseRecord)).kind).toBe("span-close");
+ });
+
+ it("preserves optional fields when present", () => {
+ const parsed = JSON.parse(serialize(spanOpenRecord));
+ expect(parsed.body).toBe("verbatim request body");
+ expect(parsed.links).toEqual([{ spanId: "span-0", turnId: "turn-0", reason: "caused-by" }]);
+ expect(parsed.attributes).toEqual({ model: "gpt-4" });
+ });
+
+ it("omits optional fields when absent (no undefined in output)", () => {
+ const parsed = JSON.parse(serialize(logRecordMinimal));
+ expect(parsed.conversationId).toBeUndefined();
+ expect(parsed.turnId).toBeUndefined();
+ expect(parsed.spanId).toBeUndefined();
+ expect(parsed.attributes).toBeUndefined();
+ expect(parsed.body).toBeUndefined();
+ expect("conversationId" in parsed).toBe(false);
+ });
});
// --- Imperative shell: createJournalSink (fs integration) ---
@@ -133,177 +133,177 @@ describe("serialize", () => {
let tmpDir: string;
beforeEach(async () => {
- tmpDir = await mkdtemp(join(tmpdir(), "journal-sink-test-"));
+ tmpDir = await mkdtemp(join(tmpdir(), "journal-sink-test-"));
});
afterEach(async () => {
- await rm(tmpDir, { recursive: true, force: true });
+ await rm(tmpDir, { recursive: true, force: true });
});
describe("createJournalSink", () => {
- it("emits records that can be read back as NDJSON", async () => {
- const path = join(tmpDir, "journal.log");
- const sink = createJournalSink({ path, fsync: "none" });
-
- const records = [logRecord, spanOpenRecord, spanCloseRecord];
- for (const r of records) {
- sink.emit(r);
- }
-
- const content = await readFile(path, "utf8");
- const lines = content.split("\n").filter((l) => l.length > 0);
- expect(lines).toHaveLength(3);
-
- for (let i = 0; i < lines.length; i++) {
- const parsed: LogRecord = JSON.parse(lines[i] ?? "");
- expect(parsed).toEqual(records[i]);
- }
- });
-
- it("warns and does NOT throw when writing to a bad path", () => {
- const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
- const badPath = join(tmpDir, "nonexistent", "deep", "journal.log");
- const sink = createJournalSink({ path: badPath, fsync: "none" });
-
- expect(() => sink.emit(logRecord)).not.toThrow();
- expect(warnSpy).toHaveBeenCalled();
- expect(warnSpy.mock.calls[0]?.[0]).toContain("[journal-sink]");
-
- warnSpy.mockRestore();
- });
-
- it("appends to existing file on creation", async () => {
- const path = join(tmpDir, "journal.log");
- const { writeFileSync } = await import("node:fs");
- writeFileSync(path, serialize(logRecord));
-
- const sink = createJournalSink({ path, fsync: "none" });
- sink.emit(spanOpenRecord);
-
- const content = await readFile(path, "utf8");
- const lines = content.split("\n").filter((l) => l.length > 0);
- expect(lines).toHaveLength(2);
- expect(JSON.parse(lines[0] ?? "").kind).toBe("log");
- expect(JSON.parse(lines[1] ?? "").kind).toBe("span-open");
- });
-
- it("warns and drops records when fs.write throws mid-emit", () => {
- const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
- const brokenFs: FsOps = {
- open: () => 1,
- write: () => {
- throw new Error("disk full");
- },
- close: () => {},
- rename: () => {},
- statSize: () => 0,
- fsync: () => {},
- };
- const sink = createJournalSink({ path: join(tmpDir, "j.log"), fs: brokenFs, fsync: "none" });
-
- expect(() => sink.emit(logRecord)).not.toThrow();
- expect(warnSpy).toHaveBeenCalled();
- expect(warnSpy.mock.calls[0]?.[1]).toBeInstanceOf(Error);
-
- warnSpy.mockRestore();
- });
+ it("emits records that can be read back as NDJSON", async () => {
+ const path = join(tmpDir, "journal.log");
+ const sink = createJournalSink({ path, fsync: "none" });
+
+ const records = [logRecord, spanOpenRecord, spanCloseRecord];
+ for (const r of records) {
+ sink.emit(r);
+ }
+
+ const content = await readFile(path, "utf8");
+ const lines = content.split("\n").filter((l) => l.length > 0);
+ expect(lines).toHaveLength(3);
+
+ for (let i = 0; i < lines.length; i++) {
+ const parsed: LogRecord = JSON.parse(lines[i] ?? "");
+ expect(parsed).toEqual(records[i]);
+ }
+ });
+
+ it("warns and does NOT throw when writing to a bad path", () => {
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const badPath = join(tmpDir, "nonexistent", "deep", "journal.log");
+ const sink = createJournalSink({ path: badPath, fsync: "none" });
+
+ expect(() => sink.emit(logRecord)).not.toThrow();
+ expect(warnSpy).toHaveBeenCalled();
+ expect(warnSpy.mock.calls[0]?.[0]).toContain("[journal-sink]");
+
+ warnSpy.mockRestore();
+ });
+
+ it("appends to existing file on creation", async () => {
+ const path = join(tmpDir, "journal.log");
+ const { writeFileSync } = await import("node:fs");
+ writeFileSync(path, serialize(logRecord));
+
+ const sink = createJournalSink({ path, fsync: "none" });
+ sink.emit(spanOpenRecord);
+
+ const content = await readFile(path, "utf8");
+ const lines = content.split("\n").filter((l) => l.length > 0);
+ expect(lines).toHaveLength(2);
+ expect(JSON.parse(lines[0] ?? "").kind).toBe("log");
+ expect(JSON.parse(lines[1] ?? "").kind).toBe("span-open");
+ });
+
+ it("warns and drops records when fs.write throws mid-emit", () => {
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const brokenFs: FsOps = {
+ open: () => 1,
+ write: () => {
+ throw new Error("disk full");
+ },
+ close: () => {},
+ rename: () => {},
+ statSize: () => 0,
+ fsync: () => {},
+ };
+ const sink = createJournalSink({ path: join(tmpDir, "j.log"), fs: brokenFs, fsync: "none" });
+
+ expect(() => sink.emit(logRecord)).not.toThrow();
+ expect(warnSpy).toHaveBeenCalled();
+ expect(warnSpy.mock.calls[0]?.[1]).toBeInstanceOf(Error);
+
+ warnSpy.mockRestore();
+ });
});
// --- Rotation ---
describe("rotation", () => {
- it("rotates when file exceeds maxBytes", async () => {
- const path = join(tmpDir, "journal.log");
- const sink = createJournalSink({ path, maxBytes: 100, fsync: "none" });
-
- sink.emit(logRecord);
- sink.emit(logRecordMinimal);
- sink.emit(spanOpenRecord);
-
- const content = await readFile(path, "utf8");
- const lines = content.split("\n").filter((l) => l.length > 0);
- expect(lines.length).toBeGreaterThan(0);
-
- let rotatedExists = false;
- try {
- await stat(`${path}.1`);
- rotatedExists = true;
- } catch {
- // May not exist if rotation hasn't triggered yet.
- }
- expect(rotatedExists).toBe(true);
-
- const rotatedContent = await readFile(`${path}.1`, "utf8");
- const allLines = [...rotatedContent.split("\n").filter((l) => l.length > 0), ...lines];
- for (const line of allLines) {
- const parsed = JSON.parse(line);
- expect(["log", "span-open", "span-close"]).toContain(parsed.kind);
- }
- });
+ it("rotates when file exceeds maxBytes", async () => {
+ const path = join(tmpDir, "journal.log");
+ const sink = createJournalSink({ path, maxBytes: 100, fsync: "none" });
+
+ sink.emit(logRecord);
+ sink.emit(logRecordMinimal);
+ sink.emit(spanOpenRecord);
+
+ const content = await readFile(path, "utf8");
+ const lines = content.split("\n").filter((l) => l.length > 0);
+ expect(lines.length).toBeGreaterThan(0);
+
+ let rotatedExists = false;
+ try {
+ await stat(`${path}.1`);
+ rotatedExists = true;
+ } catch {
+ // May not exist if rotation hasn't triggered yet.
+ }
+ expect(rotatedExists).toBe(true);
+
+ const rotatedContent = await readFile(`${path}.1`, "utf8");
+ const allLines = [...rotatedContent.split("\n").filter((l) => l.length > 0), ...lines];
+ for (const line of allLines) {
+ const parsed = JSON.parse(line);
+ expect(["log", "span-open", "span-close"]).toContain(parsed.kind);
+ }
+ });
});
// --- Fsync ---
describe("fsync", () => {
- it("calls fsync periodically when mode is periodic", () => {
- let fsyncCalls = 0;
- let currentTime = 0;
- const mockFs: FsOps = {
- open: () => 1,
- write: () => {},
- close: () => {},
- rename: () => {},
- statSize: () => 0,
- fsync: () => {
- fsyncCalls++;
- },
- };
- const mockClock: ClockOps = {
- now: () => currentTime,
- };
- const sink = createJournalSink({
- path: join(tmpDir, "j.log"),
- fs: mockFs,
- clock: mockClock,
- fsync: "periodic",
- });
-
- sink.emit(logRecord);
- expect(fsyncCalls).toBe(0);
-
- currentTime = 6_000;
- sink.emit(logRecordMinimal);
- expect(fsyncCalls).toBe(1);
-
- sink.emit(spanOpenRecord);
- expect(fsyncCalls).toBe(1);
-
- currentTime = 12_000;
- sink.emit(spanCloseRecord);
- expect(fsyncCalls).toBe(2);
- });
-
- it("never calls fsync when mode is none", () => {
- let fsyncCalls = 0;
- const mockFs: FsOps = {
- open: () => 1,
- write: () => {},
- close: () => {},
- rename: () => {},
- statSize: () => 0,
- fsync: () => {
- fsyncCalls++;
- },
- };
- const sink = createJournalSink({
- path: join(tmpDir, "j.log"),
- fs: mockFs,
- fsync: "none",
- });
-
- sink.emit(logRecord);
- sink.emit(logRecord);
- sink.emit(logRecord);
- expect(fsyncCalls).toBe(0);
- });
+ it("calls fsync periodically when mode is periodic", () => {
+ let fsyncCalls = 0;
+ let currentTime = 0;
+ const mockFs: FsOps = {
+ open: () => 1,
+ write: () => {},
+ close: () => {},
+ rename: () => {},
+ statSize: () => 0,
+ fsync: () => {
+ fsyncCalls++;
+ },
+ };
+ const mockClock: ClockOps = {
+ now: () => currentTime,
+ };
+ const sink = createJournalSink({
+ path: join(tmpDir, "j.log"),
+ fs: mockFs,
+ clock: mockClock,
+ fsync: "periodic",
+ });
+
+ sink.emit(logRecord);
+ expect(fsyncCalls).toBe(0);
+
+ currentTime = 6_000;
+ sink.emit(logRecordMinimal);
+ expect(fsyncCalls).toBe(1);
+
+ sink.emit(spanOpenRecord);
+ expect(fsyncCalls).toBe(1);
+
+ currentTime = 12_000;
+ sink.emit(spanCloseRecord);
+ expect(fsyncCalls).toBe(2);
+ });
+
+ it("never calls fsync when mode is none", () => {
+ let fsyncCalls = 0;
+ const mockFs: FsOps = {
+ open: () => 1,
+ write: () => {},
+ close: () => {},
+ rename: () => {},
+ statSize: () => 0,
+ fsync: () => {
+ fsyncCalls++;
+ },
+ };
+ const sink = createJournalSink({
+ path: join(tmpDir, "j.log"),
+ fs: mockFs,
+ fsync: "none",
+ });
+
+ sink.emit(logRecord);
+ sink.emit(logRecord);
+ sink.emit(logRecord);
+ expect(fsyncCalls).toBe(0);
+ });
});
diff --git a/packages/journal-sink/src/journal-sink.ts b/packages/journal-sink/src/journal-sink.ts
index 8e36fd6..072a013 100644
--- a/packages/journal-sink/src/journal-sink.ts
+++ b/packages/journal-sink/src/journal-sink.ts
@@ -8,32 +8,32 @@ import type { LogRecord, LogSink } from "@dispatch/kernel";
* Pure function — no I/O, no side effects.
*/
export function serialize(record: LogRecord): string {
- return `${JSON.stringify(record)}\n`;
+ return `${JSON.stringify(record)}\n`;
}
// --- Imperative shell (fs edge) ---
/** Injectable fs operations — confined to the edge. */
export interface FsOps {
- readonly open: (path: string) => number;
- readonly write: (fd: number, data: string) => void;
- readonly close: (fd: number) => void;
- readonly rename: (oldPath: string, newPath: string) => void;
- readonly statSize: (path: string) => number;
- readonly fsync: (fd: number) => void;
+ readonly open: (path: string) => number;
+ readonly write: (fd: number, data: string) => void;
+ readonly close: (fd: number) => void;
+ readonly rename: (oldPath: string, newPath: string) => void;
+ readonly statSize: (path: string) => number;
+ readonly fsync: (fd: number) => void;
}
/** Clock injection for fsync interval. */
export interface ClockOps {
- readonly now: () => number;
+ readonly now: () => number;
}
export interface JournalSinkOpts {
- readonly path: string;
- readonly maxBytes?: number;
- readonly fsync?: "periodic" | "none";
- readonly fs?: FsOps;
- readonly clock?: ClockOps;
+ readonly path: string;
+ readonly maxBytes?: number;
+ readonly fsync?: "periodic" | "none";
+ readonly fs?: FsOps;
+ readonly clock?: ClockOps;
}
const DEFAULT_MAX_BYTES = 50 * 1024 * 1024; // 50 MB
@@ -45,127 +45,127 @@ const FSYNC_INTERVAL_MS = 5_000; // 5 seconds
* Rotates when file exceeds maxBytes (rename → .1, reopen fresh).
*/
export function createJournalSink(opts: JournalSinkOpts): LogSink {
- const filePath = opts.path;
- const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
- const syncMode = opts.fsync ?? "periodic";
- const fs = opts.fs ?? createDefaultFsOps();
- const clock = opts.clock ?? { now: () => Date.now() };
-
- const NO_FD = -1;
- let fd: number;
- let bytesWritten: number;
- try {
- fd = fs.open(filePath);
- bytesWritten = fs.statSize(filePath);
- } catch {
- fd = NO_FD;
- bytesWritten = 0;
- }
- let lastFsyncAt = clock.now();
-
- function tryOpen(): boolean {
- if (fd !== NO_FD) return true;
- try {
- fd = fs.open(filePath);
- bytesWritten = 0;
- return true;
- } catch {
- return false;
- }
- }
-
- function rotate(): void {
- if (fd !== NO_FD) {
- try {
- fs.close(fd);
- } catch {
- // Ignore close errors during rotation.
- }
- }
- const rotatedPath = `${filePath}.1`;
- try {
- fs.rename(filePath, rotatedPath);
- } catch {
- // If rename fails, just truncate by reopening.
- }
- try {
- fd = fs.open(filePath);
- } catch {
- fd = NO_FD;
- }
- bytesWritten = 0;
- }
-
- function maybeFsync(): void {
- if (syncMode !== "periodic" || fd === NO_FD) return;
- const now = clock.now();
- if (now - lastFsyncAt >= FSYNC_INTERVAL_MS) {
- try {
- fs.fsync(fd);
- } catch {
- // Swallow — fail-safe.
- }
- lastFsyncAt = now;
- }
- }
-
- const sink: LogSink = {
- emit(record: LogRecord): void {
- try {
- if (!tryOpen()) {
- console.warn("[journal-sink] cannot open journal, dropping record");
- return;
- }
-
- const line = serialize(record);
- const lineBytes = Buffer.byteLength(line, "utf8");
-
- if (bytesWritten + lineBytes > maxBytes) {
- rotate();
- if (fd === NO_FD) {
- console.warn("[journal-sink] rotation failed, dropping record");
- return;
- }
- }
-
- fs.write(fd, line);
- bytesWritten += lineBytes;
- maybeFsync();
- } catch (err) {
- // Fail-safe: drop + warn, never throw to the caller (D3/D7).
- console.warn("[journal-sink] write failed, dropping record:", err);
- }
- },
- };
-
- return sink;
+ const filePath = opts.path;
+ const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
+ const syncMode = opts.fsync ?? "periodic";
+ const fs = opts.fs ?? createDefaultFsOps();
+ const clock = opts.clock ?? { now: () => Date.now() };
+
+ const NO_FD = -1;
+ let fd: number;
+ let bytesWritten: number;
+ try {
+ fd = fs.open(filePath);
+ bytesWritten = fs.statSize(filePath);
+ } catch {
+ fd = NO_FD;
+ bytesWritten = 0;
+ }
+ let lastFsyncAt = clock.now();
+
+ function tryOpen(): boolean {
+ if (fd !== NO_FD) return true;
+ try {
+ fd = fs.open(filePath);
+ bytesWritten = 0;
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ function rotate(): void {
+ if (fd !== NO_FD) {
+ try {
+ fs.close(fd);
+ } catch {
+ // Ignore close errors during rotation.
+ }
+ }
+ const rotatedPath = `${filePath}.1`;
+ try {
+ fs.rename(filePath, rotatedPath);
+ } catch {
+ // If rename fails, just truncate by reopening.
+ }
+ try {
+ fd = fs.open(filePath);
+ } catch {
+ fd = NO_FD;
+ }
+ bytesWritten = 0;
+ }
+
+ function maybeFsync(): void {
+ if (syncMode !== "periodic" || fd === NO_FD) return;
+ const now = clock.now();
+ if (now - lastFsyncAt >= FSYNC_INTERVAL_MS) {
+ try {
+ fs.fsync(fd);
+ } catch {
+ // Swallow — fail-safe.
+ }
+ lastFsyncAt = now;
+ }
+ }
+
+ const sink: LogSink = {
+ emit(record: LogRecord): void {
+ try {
+ if (!tryOpen()) {
+ console.warn("[journal-sink] cannot open journal, dropping record");
+ return;
+ }
+
+ const line = serialize(record);
+ const lineBytes = Buffer.byteLength(line, "utf8");
+
+ if (bytesWritten + lineBytes > maxBytes) {
+ rotate();
+ if (fd === NO_FD) {
+ console.warn("[journal-sink] rotation failed, dropping record");
+ return;
+ }
+ }
+
+ fs.write(fd, line);
+ bytesWritten += lineBytes;
+ maybeFsync();
+ } catch (err) {
+ // Fail-safe: drop + warn, never throw to the caller (D3/D7).
+ console.warn("[journal-sink] write failed, dropping record:", err);
+ }
+ },
+ };
+
+ return sink;
}
// --- Default fs ops (real Bun/Node I/O) ---
function createDefaultFsOps(): FsOps {
- return {
- open(path: string): number {
- return openSync(path, "a");
- },
- write(fd: number, data: string): void {
- writeSync(fd, data);
- },
- close(fd: number): void {
- closeSync(fd);
- },
- rename(oldPath: string, newPath: string): void {
- renameSync(oldPath, newPath);
- },
- statSize(path: string): number {
- try {
- return statSync(path).size;
- } catch {
- return 0;
- }
- },
- fsync(fd: number): void {
- fsyncSync(fd);
- },
- };
+ return {
+ open(path: string): number {
+ return openSync(path, "a");
+ },
+ write(fd: number, data: string): void {
+ writeSync(fd, data);
+ },
+ close(fd: number): void {
+ closeSync(fd);
+ },
+ rename(oldPath: string, newPath: string): void {
+ renameSync(oldPath, newPath);
+ },
+ statSize(path: string): number {
+ try {
+ return statSync(path).size;
+ } catch {
+ return 0;
+ }
+ },
+ fsync(fd: number): void {
+ fsyncSync(fd);
+ },
+ };
}