@toiroakr/lines-db 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,337 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { Validator } from './validator.js';
3
+ import { writeFile, mkdir, rm } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+
7
+ describe('Validator', () => {
8
+ let testDir: string;
9
+
10
+ beforeEach(async () => {
11
+ testDir = join(tmpdir(), `validator-test-${Date.now()}`);
12
+ await mkdir(testDir, { recursive: true });
13
+ });
14
+
15
+ afterEach(async () => {
16
+ await rm(testDir, { recursive: true, force: true });
17
+ });
18
+
19
+ describe('validate file', () => {
20
+ it('should validate file with valid data', async () => {
21
+ const jsonlPath = join(testDir, 'users.jsonl');
22
+ const schemaPath = join(testDir, 'users.schema.ts');
23
+
24
+ await writeFile(jsonlPath, '{"id":1,"name":"Alice"}\n{"id":2,"name":"Bob"}\n');
25
+ await writeFile(
26
+ schemaPath,
27
+ `
28
+ export const schema = {
29
+ '~standard': {
30
+ version: 1,
31
+ vendor: 'test',
32
+ validate: (data) => {
33
+ const issues = [];
34
+ if (!data.id || !data.name) {
35
+ issues.push({ message: 'Missing required fields' });
36
+ }
37
+ return { value: data, issues };
38
+ }
39
+ }
40
+ };
41
+ `,
42
+ );
43
+
44
+ const validator = new Validator({ path: jsonlPath });
45
+ const result = await validator.validate();
46
+
47
+ expect(result.valid).toBe(true);
48
+ expect(result.errors).toHaveLength(0);
49
+ });
50
+
51
+ it('should detect validation errors', async () => {
52
+ const jsonlPath = join(testDir, 'users.jsonl');
53
+ const schemaPath = join(testDir, 'users.schema.ts');
54
+
55
+ await writeFile(jsonlPath, '{"id":1,"name":"Alice"}\n{"id":2}\n');
56
+ await writeFile(
57
+ schemaPath,
58
+ `
59
+ export const schema = {
60
+ '~standard': {
61
+ version: 1,
62
+ vendor: 'test',
63
+ validate: (data) => {
64
+ const issues = [];
65
+ if (!data.name) {
66
+ issues.push({ message: 'Missing name field', path: ['name'] });
67
+ }
68
+ return { value: data, issues };
69
+ }
70
+ }
71
+ };
72
+ `,
73
+ );
74
+
75
+ const validator = new Validator({ path: jsonlPath });
76
+ const result = await validator.validate();
77
+
78
+ expect(result.valid).toBe(false);
79
+ expect(result.errors).toHaveLength(1);
80
+ expect(result.errors[0].rowIndex).toBe(2);
81
+ expect(result.errors[0].tableName).toBe('users');
82
+ expect(result.errors[0].issues).toHaveLength(1);
83
+ });
84
+
85
+ it('should throw when no schema exists', async () => {
86
+ const jsonlPath = join(testDir, 'users.jsonl');
87
+ await writeFile(jsonlPath, '{"id":1}\n{"id":2}\n');
88
+
89
+ const validator = new Validator({ path: jsonlPath });
90
+ await expect(validator.validate()).rejects.toThrow(/Schema file not found/);
91
+ });
92
+
93
+ it('should handle multiple validation errors in single file', async () => {
94
+ const jsonlPath = join(testDir, 'users.jsonl');
95
+ const schemaPath = join(testDir, 'users.schema.ts');
96
+
97
+ await writeFile(jsonlPath, '{"id":1}\n{"id":2}\n{"id":3,"name":"Charlie"}\n');
98
+ await writeFile(
99
+ schemaPath,
100
+ `
101
+ export const schema = {
102
+ '~standard': {
103
+ version: 1,
104
+ vendor: 'test',
105
+ validate: (data) => {
106
+ const issues = [];
107
+ if (!data.name) {
108
+ issues.push({ message: 'Missing name' });
109
+ }
110
+ return { value: data, issues };
111
+ }
112
+ }
113
+ };
114
+ `,
115
+ );
116
+
117
+ const validator = new Validator({ path: jsonlPath });
118
+ const result = await validator.validate();
119
+
120
+ expect(result.valid).toBe(false);
121
+ expect(result.errors).toHaveLength(2);
122
+ expect(result.errors[0].rowIndex).toBe(1);
123
+ expect(result.errors[1].rowIndex).toBe(2);
124
+ });
125
+
126
+ it('should return 0-based rowIndex (first line = index 0)', async () => {
127
+ const jsonlPath = join(testDir, 'users.jsonl');
128
+ const schemaPath = join(testDir, 'users.schema.ts');
129
+
130
+ // Line 1: valid, Line 2: invalid (no name), Line 3: invalid (no name)
131
+ await writeFile(
132
+ jsonlPath,
133
+ '{"id":1,"name":"Alice"}\n{"id":2}\n{"id":3}\n',
134
+ );
135
+ await writeFile(
136
+ schemaPath,
137
+ `
138
+ export const schema = {
139
+ '~standard': {
140
+ version: 1,
141
+ vendor: 'test',
142
+ validate: (data) => {
143
+ const issues = [];
144
+ if (!data.name) {
145
+ issues.push({ message: 'Missing name field', path: ['name'] });
146
+ }
147
+ return { value: data, issues };
148
+ }
149
+ }
150
+ };
151
+ `,
152
+ );
153
+
154
+ const validator = new Validator({ path: jsonlPath });
155
+ const result = await validator.validate();
156
+
157
+ expect(result.valid).toBe(false);
158
+ expect(result.errors).toHaveLength(2);
159
+ // First error is on line 2 of the file, which is index 1 (0-based)
160
+ expect(result.errors[0].rowIndex).toBe(1);
161
+ expect(result.errors[0].file).toBe(jsonlPath);
162
+ // Second error is on line 3 of the file, which is index 2 (0-based)
163
+ expect(result.errors[1].rowIndex).toBe(2);
164
+ expect(result.errors[1].file).toBe(jsonlPath);
165
+ });
166
+
167
+ it('should return 0-based rowIndex for first line error', async () => {
168
+ const jsonlPath = join(testDir, 'users.jsonl');
169
+ const schemaPath = join(testDir, 'users.schema.ts');
170
+
171
+ // First line has error
172
+ await writeFile(jsonlPath, '{"id":1}\n{"id":2,"name":"Bob"}\n');
173
+ await writeFile(
174
+ schemaPath,
175
+ `
176
+ export const schema = {
177
+ '~standard': {
178
+ version: 1,
179
+ vendor: 'test',
180
+ validate: (data) => {
181
+ const issues = [];
182
+ if (!data.name) {
183
+ issues.push({ message: 'Missing name field', path: ['name'] });
184
+ }
185
+ return { value: data, issues };
186
+ }
187
+ }
188
+ };
189
+ `,
190
+ );
191
+
192
+ const validator = new Validator({ path: jsonlPath });
193
+ const result = await validator.validate();
194
+
195
+ expect(result.valid).toBe(false);
196
+ expect(result.errors).toHaveLength(1);
197
+ // First line (line 1 in file) should be index 0
198
+ expect(result.errors[0].rowIndex).toBe(0);
199
+ expect(result.errors[0].file).toBe(jsonlPath);
200
+ });
201
+ });
202
+
203
+ describe('validate directory', () => {
204
+ it('should validate all JSONL files in directory', async () => {
205
+ const usersPath = join(testDir, 'users.jsonl');
206
+ const productsPath = join(testDir, 'products.jsonl');
207
+ const usersSchemaPath = join(testDir, 'users.schema.ts');
208
+ const productsSchemaPath = join(testDir, 'products.schema.ts');
209
+
210
+ await writeFile(usersPath, '{"id":1,"name":"Alice"}\n');
211
+ await writeFile(productsPath, '{"id":1,"name":"Product"}\n');
212
+ await writeFile(
213
+ usersSchemaPath,
214
+ `
215
+ export const schema = {
216
+ '~standard': {
217
+ version: 1,
218
+ vendor: 'test',
219
+ validate: (data) => ({ value: data, issues: [] })
220
+ }
221
+ };
222
+ `,
223
+ );
224
+ await writeFile(
225
+ productsSchemaPath,
226
+ `
227
+ export const schema = {
228
+ '~standard': {
229
+ version: 1,
230
+ vendor: 'test',
231
+ validate: (data) => ({ value: data, issues: [] })
232
+ }
233
+ };
234
+ `,
235
+ );
236
+
237
+ const validator = new Validator({ path: testDir });
238
+ const result = await validator.validate();
239
+
240
+ expect(result.valid).toBe(true);
241
+ expect(result.errors).toHaveLength(0);
242
+ });
243
+
244
+ it('should collect errors from multiple files', async () => {
245
+ const usersPath = join(testDir, 'users.jsonl');
246
+ const usersSchemaPath = join(testDir, 'users.schema.ts');
247
+ const productsPath = join(testDir, 'products.jsonl');
248
+ const productsSchemaPath = join(testDir, 'products.schema.ts');
249
+
250
+ await writeFile(usersPath, '{"id":1}\n');
251
+ await writeFile(
252
+ usersSchemaPath,
253
+ `
254
+ export const schema = {
255
+ '~standard': {
256
+ version: 1,
257
+ vendor: 'test',
258
+ validate: (data) => ({
259
+ value: data,
260
+ issues: data.name ? [] : [{ message: 'Missing name' }]
261
+ })
262
+ }
263
+ };
264
+ `,
265
+ );
266
+
267
+ await writeFile(productsPath, '{"id":1}\n');
268
+ await writeFile(
269
+ productsSchemaPath,
270
+ `
271
+ export const schema = {
272
+ '~standard': {
273
+ version: 1,
274
+ vendor: 'test',
275
+ validate: (data) => ({
276
+ value: data,
277
+ issues: data.price ? [] : [{ message: 'Missing price' }]
278
+ })
279
+ }
280
+ };
281
+ `,
282
+ );
283
+
284
+ const validator = new Validator({ path: testDir });
285
+ const result = await validator.validate();
286
+
287
+ expect(result.valid).toBe(false);
288
+ expect(result.errors).toHaveLength(2);
289
+ });
290
+
291
+ it('should throw error for directory with no JSONL files', async () => {
292
+ await writeFile(join(testDir, 'readme.txt'), 'no jsonl files here');
293
+
294
+ const validator = new Validator({ path: testDir });
295
+
296
+ await expect(validator.validate()).rejects.toThrow('No JSONL files found');
297
+ });
298
+ });
299
+
300
+ describe('error handling', () => {
301
+ it('should throw error for non-JSONL file', async () => {
302
+ const txtPath = join(testDir, 'test.txt');
303
+ await writeFile(txtPath, 'not a jsonl file');
304
+
305
+ const validator = new Validator({ path: txtPath });
306
+
307
+ await expect(validator.validate()).rejects.toThrow('Invalid path');
308
+ });
309
+
310
+ it('should reject async validation', async () => {
311
+ const jsonlPath = join(testDir, 'users.jsonl');
312
+ const schemaPath = join(testDir, 'users.schema.ts');
313
+
314
+ await writeFile(jsonlPath, '{"id":1}\n');
315
+ await writeFile(
316
+ schemaPath,
317
+ `
318
+ export const schema = {
319
+ '~standard': {
320
+ version: 1,
321
+ vendor: 'test',
322
+ validate: async (data) => {
323
+ return { value: data, issues: [] };
324
+ }
325
+ }
326
+ };
327
+ `,
328
+ );
329
+
330
+ const validator = new Validator({ path: jsonlPath });
331
+
332
+ await expect(validator.validate()).rejects.toThrow(
333
+ 'Asynchronous validation is not supported',
334
+ );
335
+ });
336
+ });
337
+ });
@@ -0,0 +1,207 @@
1
+ import { readdir, stat } from 'node:fs/promises';
2
+ import { join, basename } from 'node:path';
3
+ import { JsonlReader } from './jsonl-reader.js';
4
+ import { SchemaLoader } from './schema-loader.js';
5
+ import type { StandardSchemaIssue, JsonObject } from './types.js';
6
+ import type { BiDirectionalSchema } from './schema.js';
7
+
8
+ export interface ValidationResult {
9
+ valid: boolean;
10
+ errors: ValidationErrorDetail[];
11
+ }
12
+
13
+ export interface ValidationErrorDetail {
14
+ file: string;
15
+ tableName: string;
16
+ rowIndex: number;
17
+ issues: ReadonlyArray<StandardSchemaIssue>;
18
+ type?: 'schema' | 'foreignKey';
19
+ foreignKeyError?: {
20
+ column: string;
21
+ value: unknown;
22
+ referencedTable: string;
23
+ referencedColumn: string;
24
+ };
25
+ }
26
+
27
+ export interface ValidatorOptions {
28
+ path: string; // File or directory path
29
+ projectRoot?: string;
30
+ }
31
+
32
+ export class Validator {
33
+ private path: string;
34
+ private projectRoot: string;
35
+
36
+ constructor(options: ValidatorOptions) {
37
+ this.path = options.path;
38
+ this.projectRoot = options.projectRoot || process.cwd();
39
+ }
40
+
41
+ /**
42
+ * Validate JSONL file(s)
43
+ */
44
+ async validate(): Promise<ValidationResult> {
45
+ // Use absolute path if provided, otherwise resolve relative to projectRoot
46
+ const fullPath = this.path.startsWith('/') ? this.path : join(this.projectRoot, this.path);
47
+ const stats = await stat(fullPath);
48
+
49
+ if (stats.isDirectory()) {
50
+ return this.validateDirectory(fullPath);
51
+ } else if (stats.isFile() && fullPath.endsWith('.jsonl')) {
52
+ return this.validateFile(fullPath);
53
+ } else {
54
+ throw new Error(`Invalid path: ${this.path}. Must be a directory or .jsonl file.`);
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Validate all JSONL files in a directory
60
+ */
61
+ private async validateDirectory(dirPath: string): Promise<ValidationResult> {
62
+ const entries = await readdir(dirPath, { withFileTypes: true });
63
+ const jsonlFiles = entries
64
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl'))
65
+ .map((entry) => join(dirPath, entry.name));
66
+
67
+ if (jsonlFiles.length === 0) {
68
+ throw new Error(`No JSONL files found in directory: ${dirPath}`);
69
+ }
70
+
71
+ const allErrors: ValidationErrorDetail[] = [];
72
+
73
+ // First, validate schema for each file
74
+ for (const file of jsonlFiles) {
75
+ const result = await this.validateFile(file);
76
+ allErrors.push(...result.errors);
77
+ }
78
+
79
+ // Then, validate foreign keys across all tables
80
+ const fkErrors = await this.validateForeignKeys(dirPath, jsonlFiles);
81
+ allErrors.push(...fkErrors);
82
+
83
+ return {
84
+ valid: allErrors.length === 0,
85
+ errors: allErrors,
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Validate foreign key constraints across all tables
91
+ */
92
+ private async validateForeignKeys(
93
+ dirPath: string,
94
+ jsonlFiles: string[],
95
+ ): Promise<ValidationErrorDetail[]> {
96
+ const errors: ValidationErrorDetail[] = [];
97
+
98
+ // Load all table data
99
+ const tableData = new Map<string, JsonObject[]>();
100
+ const tableSchemas = new Map<string, BiDirectionalSchema>();
101
+
102
+ for (const file of jsonlFiles) {
103
+ const tableName = basename(file, '.jsonl');
104
+ const data = await JsonlReader.read(file);
105
+ const schema = await SchemaLoader.loadSchema(file);
106
+
107
+ tableData.set(tableName, data);
108
+ tableSchemas.set(tableName, schema as BiDirectionalSchema);
109
+ }
110
+
111
+ // Check foreign keys for each table
112
+ for (const file of jsonlFiles) {
113
+ const tableName = basename(file, '.jsonl');
114
+ const schema = tableSchemas.get(tableName);
115
+ const data = tableData.get(tableName);
116
+
117
+ if (!schema || !data || !schema.foreignKeys) {
118
+ continue;
119
+ }
120
+
121
+ // Check each foreign key constraint
122
+ for (const fk of schema.foreignKeys) {
123
+ const referencedTable = fk.references.table;
124
+ const referencedData = tableData.get(referencedTable);
125
+
126
+ if (!referencedData) {
127
+ // Referenced table not found - skip validation
128
+ continue;
129
+ }
130
+
131
+ // Build index of referenced values for fast lookup
132
+ const referencedValues = new Set<string>();
133
+ for (const refRow of referencedData) {
134
+ // Build composite key from referenced columns
135
+ const keyValues = fk.references.columns.map((col) => refRow[col]);
136
+ const compositeKey = JSON.stringify(keyValues);
137
+ referencedValues.add(compositeKey);
138
+ }
139
+
140
+ // Check each row in current table
141
+ for (let i = 0; i < data.length; i++) {
142
+ const row = data[i];
143
+ const foreignKeyValues = fk.columns.map((col) => row[col]);
144
+ const compositeKey = JSON.stringify(foreignKeyValues);
145
+
146
+ // Check if foreign key value exists in referenced table
147
+ if (!referencedValues.has(compositeKey)) {
148
+ errors.push({
149
+ file,
150
+ tableName,
151
+ rowIndex: i, // 0-indexed, will be converted to 1-indexed in formatter
152
+ issues: [],
153
+ type: 'foreignKey',
154
+ foreignKeyError: {
155
+ column: fk.columns.join(', '),
156
+ value: foreignKeyValues.length === 1 ? foreignKeyValues[0] : foreignKeyValues,
157
+ referencedTable: referencedTable,
158
+ referencedColumn: fk.references.columns.join(', '),
159
+ },
160
+ });
161
+ }
162
+ }
163
+ }
164
+ }
165
+
166
+ return errors;
167
+ }
168
+
169
+ /**
170
+ * Validate a single JSONL file
171
+ */
172
+ private async validateFile(filePath: string): Promise<ValidationResult> {
173
+ const tableName = basename(filePath, '.jsonl');
174
+ const data = await JsonlReader.read(filePath);
175
+
176
+ // Try to load schema
177
+ const schema = await SchemaLoader.loadSchema(filePath);
178
+
179
+ const errors: ValidationErrorDetail[] = [];
180
+
181
+ // Validate each row
182
+ for (let i = 0; i < data.length; i++) {
183
+ const row = data[i];
184
+ const result = schema['~standard'].validate(row);
185
+
186
+ // Only synchronous validation is supported
187
+ if (result instanceof Promise) {
188
+ throw new Error('Asynchronous validation is not supported.');
189
+ }
190
+
191
+ if (result.issues && result.issues.length > 0) {
192
+ errors.push({
193
+ file: filePath,
194
+ tableName,
195
+ rowIndex: i, // 0-indexed, will be converted to 1-indexed in formatter
196
+ issues: result.issues,
197
+ type: 'schema',
198
+ });
199
+ }
200
+ }
201
+
202
+ return {
203
+ valid: errors.length === 0,
204
+ errors,
205
+ };
206
+ }
207
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "lib": ["ES2022"],
7
+ "outDir": "./dist",
8
+ "rootDir": "./src",
9
+ "strict": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "forceConsistentCasingInFileNames": true,
13
+ "resolveJsonModule": true,
14
+ "declaration": true,
15
+ "declarationMap": true,
16
+ "sourceMap": true
17
+ },
18
+ "include": ["src/**/*"],
19
+ "exclude": ["node_modules", "dist"]
20
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "noEmit": true,
5
+ "types": ["vitest/globals", "node"]
6
+ },
7
+ "include": ["src/**/*.test.ts", "src/**/*.ts"]
8
+ }
@@ -0,0 +1,26 @@
1
+ import { defineConfig } from 'tsdown';
2
+
3
+ export default defineConfig([
4
+ // CLI build (ESM only)
5
+ {
6
+ entry: ['src/cli.ts'],
7
+ format: ['esm'],
8
+ platform: 'node',
9
+ target: 'node18',
10
+ clean: true,
11
+ shims: true,
12
+ outDir: 'bin',
13
+ dts: false,
14
+ treeshake: true,
15
+ },
16
+ // Library build (ESM + CJS)
17
+ {
18
+ entry: ['src/index.ts'],
19
+ format: ['esm', 'cjs'],
20
+ platform: 'node',
21
+ target: 'node18',
22
+ outDir: 'dist',
23
+ dts: true,
24
+ treeshake: true,
25
+ },
26
+ ]);
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: 'node',
7
+ include: ['src/**/*.test.ts'],
8
+ },
9
+ });