Skip to content

Commit c03b2c4

Browse files
author
John Doe
committed
refactor: add general file sink logic
1 parent 9d4a223 commit c03b2c4

7 files changed

Lines changed: 1088 additions & 0 deletions
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import * as fs from 'node:fs';
2+
import * as os from 'node:os';
3+
import * as path from 'node:path';
4+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
5+
import { teardownTestFolder } from '@code-pushup/test-utils';
6+
import { JsonlFileSink, recoverJsonlFile } from './file-sink-json.js';
7+
8+
describe('JsonlFileSink integration', () => {
9+
const baseDir = path.join(os.tmpdir(), 'file-sink-json-int-tests');
10+
const testFile = path.join(baseDir, 'test-data.jsonl');
11+
12+
beforeAll(async () => {
13+
await fs.promises.mkdir(baseDir, { recursive: true });
14+
});
15+
16+
beforeEach(async () => {
17+
try {
18+
await fs.promises.unlink(testFile);
19+
} catch {
20+
// File doesn't exist, which is fine
21+
}
22+
});
23+
24+
afterAll(async () => {
25+
await teardownTestFolder(baseDir);
26+
});
27+
28+
describe('file operations', () => {
29+
const testData = [
30+
{ id: 1, name: 'Alice', active: true },
31+
{ id: 2, name: 'Bob', active: false },
32+
{ id: 3, name: 'Charlie', active: true },
33+
];
34+
35+
it('should write and read JSONL files', async () => {
36+
const sink = new JsonlFileSink({ filePath: testFile });
37+
38+
// Open and write data
39+
sink.open();
40+
testData.forEach(item => sink.write(item));
41+
sink.close();
42+
43+
expect(fs.existsSync(testFile)).toBe(true);
44+
const fileContent = fs.readFileSync(testFile, 'utf8');
45+
const lines = fileContent.trim().split('\n');
46+
expect(lines).toStrictEqual([
47+
'{"id":1,"name":"Alice","active":true}',
48+
'{"id":2,"name":"Bob","active":false}',
49+
'{"id":3,"name":"Charlie","active":true}',
50+
]);
51+
52+
lines.forEach((line, index) => {
53+
const parsed = JSON.parse(line);
54+
expect(parsed).toStrictEqual(testData[index]);
55+
});
56+
});
57+
58+
it('should recover data from JSONL files', async () => {
59+
const jsonlContent = `${testData.map(item => JSON.stringify(item)).join('\n')}\n`;
60+
fs.writeFileSync(testFile, jsonlContent);
61+
62+
expect(recoverJsonlFile(testFile)).toStrictEqual({
63+
records: testData,
64+
errors: [],
65+
partialTail: null,
66+
});
67+
});
68+
69+
it('should handle JSONL files with parse errors', async () => {
70+
const mixedContent =
71+
'{"id":1,"name":"Alice"}\n' +
72+
'invalid json line\n' +
73+
'{"id":2,"name":"Bob"}\n' +
74+
'{"id":3,"name":"Charlie","incomplete":\n';
75+
76+
fs.writeFileSync(testFile, mixedContent);
77+
78+
expect(recoverJsonlFile(testFile)).toStrictEqual({
79+
records: [
80+
{ id: 1, name: 'Alice' },
81+
{ id: 2, name: 'Bob' },
82+
],
83+
errors: [
84+
expect.objectContaining({ line: 'invalid json line' }),
85+
expect.objectContaining({
86+
line: '{"id":3,"name":"Charlie","incomplete":',
87+
}),
88+
],
89+
partialTail: '{"id":3,"name":"Charlie","incomplete":',
90+
});
91+
});
92+
93+
it('should recover data using JsonlFileSink.recover()', async () => {
94+
const sink = new JsonlFileSink({ filePath: testFile });
95+
sink.open();
96+
testData.forEach(item => sink.write(item));
97+
sink.close();
98+
99+
expect(sink.recover()).toStrictEqual({
100+
records: testData,
101+
errors: [],
102+
partialTail: null,
103+
});
104+
});
105+
106+
describe('edge cases', () => {
107+
it('should handle empty files', async () => {
108+
fs.writeFileSync(testFile, '');
109+
110+
expect(recoverJsonlFile(testFile)).toStrictEqual({
111+
records: [],
112+
errors: [],
113+
partialTail: null,
114+
});
115+
});
116+
117+
it('should handle files with only whitespace', async () => {
118+
fs.writeFileSync(testFile, ' \n \n\t\n');
119+
120+
expect(recoverJsonlFile(testFile)).toStrictEqual({
121+
records: [],
122+
errors: [],
123+
partialTail: null,
124+
});
125+
});
126+
127+
it('should handle non-existent files', async () => {
128+
const nonExistentFile = path.join(baseDir, 'does-not-exist.jsonl');
129+
130+
expect(recoverJsonlFile(nonExistentFile)).toStrictEqual({
131+
records: [],
132+
errors: [],
133+
partialTail: null,
134+
});
135+
});
136+
});
137+
});
138+
});
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import * as fs from 'node:fs';
2+
import {
3+
type FileOutput,
4+
FileSink,
5+
type FileSinkOptions,
6+
stringDecode,
7+
stringEncode,
8+
stringRecover,
9+
} from './file-sink-text.js';
10+
import type { RecoverOptions, RecoverResult } from './sink-source.types.js';
11+
12+
export const jsonlEncode = <
13+
T extends Record<string, unknown> = Record<string, unknown>,
14+
>(
15+
input: T,
16+
): FileOutput => JSON.stringify(input);
17+
18+
export const jsonlDecode = <
19+
T extends Record<string, unknown> = Record<string, unknown>,
20+
>(
21+
output: FileOutput,
22+
): T => JSON.parse(stringDecode(output)) as T;
23+
24+
export function recoverJsonlFile<
25+
T extends Record<string, unknown> = Record<string, unknown>,
26+
>(filePath: string, opts: RecoverOptions = {}): RecoverResult<T> {
27+
return stringRecover(filePath, jsonlDecode<T>, opts);
28+
}
29+
30+
export class JsonlFileSink<
31+
T extends Record<string, unknown> = Record<string, unknown>,
32+
> extends FileSink<T> {
33+
constructor(options: FileSinkOptions) {
34+
const { filePath, ...fileOptions } = options;
35+
super({
36+
...fileOptions,
37+
filePath,
38+
recover: () => recoverJsonlFile<T>(filePath),
39+
finalize: () => {
40+
// No additional finalization needed for JSONL files
41+
},
42+
});
43+
}
44+
45+
override encode(input: T): FileOutput {
46+
return stringEncode(jsonlEncode(input));
47+
}
48+
49+
override decode(output: FileOutput): T {
50+
return jsonlDecode(stringDecode(output));
51+
}
52+
53+
override repack(outputPath?: string): void {
54+
const { records } = this.recover();
55+
fs.writeFileSync(
56+
outputPath ?? this.getFilePath(),
57+
records.map(this.encode).join(''),
58+
);
59+
}
60+
}

0 commit comments

Comments
 (0)