@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,91 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { DirectoryScanner } from './directory-scanner.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('DirectoryScanner', () => {
8
+ let testDir: string;
9
+
10
+ beforeEach(async () => {
11
+ testDir = join(tmpdir(), `scanner-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('scanDirectory', () => {
20
+ it('should find JSONL files in directory', async () => {
21
+ await writeFile(join(testDir, 'users.jsonl'), '{"id":1}\n');
22
+ await writeFile(join(testDir, 'products.jsonl'), '{"id":1}\n');
23
+
24
+ const tables = await DirectoryScanner.scanDirectory(testDir);
25
+
26
+ expect(tables.size).toBe(2);
27
+ expect(tables.has('users')).toBe(true);
28
+ expect(tables.has('products')).toBe(true);
29
+ });
30
+
31
+ it('should create correct table config', async () => {
32
+ await writeFile(join(testDir, 'users.jsonl'), '{"id":1}\n');
33
+
34
+ const tables = await DirectoryScanner.scanDirectory(testDir);
35
+ const usersConfig = tables.get('users');
36
+
37
+ expect(usersConfig).toBeDefined();
38
+ expect(usersConfig?.jsonlPath).toBe(join(testDir, 'users.jsonl'));
39
+ expect(usersConfig?.autoInferSchema).toBe(true);
40
+ });
41
+
42
+ it('should ignore non-JSONL files', async () => {
43
+ await writeFile(join(testDir, 'users.jsonl'), '{"id":1}\n');
44
+ await writeFile(join(testDir, 'readme.txt'), 'readme');
45
+ await writeFile(join(testDir, 'config.json'), '{}');
46
+
47
+ const tables = await DirectoryScanner.scanDirectory(testDir);
48
+
49
+ expect(tables.size).toBe(1);
50
+ expect(tables.has('users')).toBe(true);
51
+ });
52
+
53
+ it('should handle empty directory', async () => {
54
+ const tables = await DirectoryScanner.scanDirectory(testDir);
55
+
56
+ expect(tables.size).toBe(0);
57
+ });
58
+
59
+ it('should throw error for non-existent directory', async () => {
60
+ const nonExistentDir = join(testDir, 'nonexistent');
61
+
62
+ await expect(DirectoryScanner.scanDirectory(nonExistentDir)).rejects.toThrow(
63
+ 'Failed to scan directory',
64
+ );
65
+ });
66
+
67
+ it('should handle multiple JSONL files', async () => {
68
+ const fileNames = ['users', 'products', 'orders', 'categories', 'reviews'];
69
+
70
+ for (const name of fileNames) {
71
+ await writeFile(join(testDir, `${name}.jsonl`), '{"id":1}\n');
72
+ }
73
+
74
+ const tables = await DirectoryScanner.scanDirectory(testDir);
75
+
76
+ expect(tables.size).toBe(5);
77
+ for (const name of fileNames) {
78
+ expect(tables.has(name)).toBe(true);
79
+ }
80
+ });
81
+
82
+ it('should handle files with multiple dots in name', async () => {
83
+ await writeFile(join(testDir, 'test.backup.jsonl'), '{"id":1}\n');
84
+
85
+ const tables = await DirectoryScanner.scanDirectory(testDir);
86
+
87
+ expect(tables.size).toBe(1);
88
+ expect(tables.has('test.backup')).toBe(true);
89
+ });
90
+ });
91
+ });
@@ -0,0 +1,38 @@
1
+ import { readdir } from 'node:fs/promises';
2
+ import { join, basename, extname } from 'node:path';
3
+ import type { TableConfig } from './types.js';
4
+
5
+ export class DirectoryScanner {
6
+ /**
7
+ * Scan directory for JSONL files and create table configurations
8
+ */
9
+ static async scanDirectory(dataDir: string): Promise<Map<string, TableConfig>> {
10
+ const tables = new Map<string, TableConfig>();
11
+
12
+ try {
13
+ const files = await readdir(dataDir);
14
+
15
+ for (const file of files) {
16
+ if (extname(file) === '.jsonl') {
17
+ const tableName = basename(file, '.jsonl');
18
+ const jsonlPath = join(dataDir, file);
19
+
20
+ tables.set(tableName, {
21
+ jsonlPath,
22
+ autoInferSchema: true,
23
+ });
24
+ }
25
+ }
26
+
27
+ if (tables.size === 0) {
28
+ console.warn(`Warning: No JSONL files found in directory: ${dataDir}`);
29
+ }
30
+
31
+ return tables;
32
+ } catch (error) {
33
+ throw new Error(
34
+ `Failed to scan directory ${dataDir}: ${error instanceof Error ? error.message : String(error)}`,
35
+ );
36
+ }
37
+ }
38
+ }
@@ -0,0 +1,166 @@
1
+ import type { StandardSchemaIssue } from './types.js';
2
+ import { styleText } from 'node:util';
3
+
4
+ export interface ValidationErrorInfo {
5
+ file: string;
6
+ rowIndex: number;
7
+ issues: ReadonlyArray<StandardSchemaIssue>;
8
+ data?: unknown;
9
+ originalData?: unknown;
10
+ }
11
+
12
+ export interface ForeignKeyErrorInfo {
13
+ file: string;
14
+ rowIndex: number;
15
+ column: string;
16
+ value: unknown;
17
+ referencedTable: string;
18
+ referencedColumn: string;
19
+ data?: unknown;
20
+ }
21
+
22
+ export interface ErrorFormatterOptions {
23
+ verbose?: boolean;
24
+ }
25
+
26
+ export class ErrorFormatter {
27
+ private verbose: boolean;
28
+
29
+ constructor(options: ErrorFormatterOptions = {}) {
30
+ this.verbose = options.verbose ?? false;
31
+ }
32
+
33
+ /**
34
+ * Format validation errors
35
+ */
36
+ formatValidationErrors(errors: ValidationErrorInfo[]): string {
37
+ if (this.verbose) {
38
+ return this.formatValidationErrorsVerbose(errors);
39
+ }
40
+ return this.formatValidationErrorsCompact(errors);
41
+ }
42
+
43
+ /**
44
+ * Format foreign key error
45
+ */
46
+ formatForeignKeyError(error: ForeignKeyErrorInfo): string {
47
+ if (this.verbose) {
48
+ return this.formatForeignKeyErrorVerbose(error);
49
+ }
50
+ return this.formatForeignKeyErrorCompact(error);
51
+ }
52
+
53
+ /**
54
+ * Format compact (default) validation errors
55
+ */
56
+ private formatValidationErrorsCompact(errors: ValidationErrorInfo[]): string {
57
+ const lines: string[] = [];
58
+
59
+ for (const error of errors) {
60
+ for (const issue of error.issues) {
61
+ const fieldPath = this.getFieldPath(issue);
62
+ const line = `${error.file}:${error.rowIndex + 1} • ${fieldPath}: ${issue.message}`;
63
+ lines.push(line);
64
+ }
65
+ }
66
+
67
+ return lines.join('\n');
68
+ }
69
+
70
+ /**
71
+ * Format verbose validation errors
72
+ */
73
+ private formatValidationErrorsVerbose(errors: ValidationErrorInfo[]): string {
74
+ const blocks: string[] = [];
75
+
76
+ for (let i = 0; i < errors.length; i++) {
77
+ const error = errors[i];
78
+ const isLast = i === errors.length - 1;
79
+ const prefix = isLast ? '└─' : '├─';
80
+ const linePrefix = isLast ? ' ' : '│ ';
81
+
82
+ const lines: string[] = [`${prefix} ${error.file}:${error.rowIndex + 1}`];
83
+
84
+ for (const issue of error.issues) {
85
+ const fieldPath = this.getFieldPath(issue);
86
+ lines.push(`${linePrefix}Field: ${fieldPath}`);
87
+ lines.push(`${linePrefix}Error: ${issue.message}`);
88
+ }
89
+
90
+ // Show original data if available (migrate case)
91
+ if (error.originalData !== undefined) {
92
+ lines.push(`${linePrefix}Original data: ${JSON.stringify(error.originalData)}`);
93
+ }
94
+
95
+ // Show data
96
+ if (error.data !== undefined) {
97
+ const label = error.originalData !== undefined ? 'Transformed data' : 'Data';
98
+ lines.push(`${linePrefix}${label}: ${JSON.stringify(error.data)}`);
99
+ }
100
+
101
+ blocks.push(lines.join('\n'));
102
+ }
103
+
104
+ return blocks.join('\n│\n');
105
+ }
106
+
107
+ /**
108
+ * Format compact foreign key error
109
+ */
110
+ private formatForeignKeyErrorCompact(error: ForeignKeyErrorInfo): string {
111
+ return `${error.file}:${error.rowIndex + 1} • ${error.column}: Foreign key constraint failed - Referenced value ${JSON.stringify(error.value)} does not exist in ${error.referencedTable}(${error.referencedColumn})`;
112
+ }
113
+
114
+ /**
115
+ * Format verbose foreign key error
116
+ */
117
+ private formatForeignKeyErrorVerbose(error: ForeignKeyErrorInfo): string {
118
+ const lines: string[] = [
119
+ `└─ ${error.file}:${error.rowIndex + 1}`,
120
+ ` Type: Foreign Key Violation`,
121
+ ` Field: ${error.column}`,
122
+ ` Value: ${JSON.stringify(error.value)}`,
123
+ ` References: ${error.referencedTable}(${error.referencedColumn})`,
124
+ ` Error: Referenced value does not exist in target table`,
125
+ ];
126
+
127
+ if (error.data !== undefined) {
128
+ lines.push(` Data: ${JSON.stringify(error.data)}`);
129
+ }
130
+
131
+ return lines.join('\n');
132
+ }
133
+
134
+ /**
135
+ * Get field path from issue
136
+ */
137
+ private getFieldPath(issue: StandardSchemaIssue): string {
138
+ if (!issue.path || issue.path.length === 0) {
139
+ return 'root';
140
+ }
141
+
142
+ return issue.path
143
+ .map((segment) => {
144
+ if (typeof segment === 'object' && segment !== null && 'key' in segment) {
145
+ return String(segment.key);
146
+ }
147
+ return String(segment);
148
+ })
149
+ .join('.');
150
+ }
151
+
152
+ /**
153
+ * Format error header with count
154
+ */
155
+ formatErrorHeader(count: number, file?: string): string {
156
+ const fileInfo = file ? ` in ${file}` : '';
157
+ return styleText('red', `✗ Found ${count} error(s)${fileInfo}`);
158
+ }
159
+
160
+ /**
161
+ * Format migration failure header
162
+ */
163
+ formatMigrationFailureHeader(): string {
164
+ return styleText('red', '\n✗ Migration failed and was rolled back');
165
+ }
166
+ }
package/src/index.ts ADDED
@@ -0,0 +1,35 @@
1
+ export { LinesDB } from './database.js';
2
+ export { JsonlReader } from './jsonl-reader.js';
3
+ export { JsonlWriter } from './jsonl-writer.js';
4
+ export { SchemaLoader } from './schema-loader.js';
5
+ export { DirectoryScanner } from './directory-scanner.js';
6
+ export { defineSchema, hasBackward } from './schema.js';
7
+ export { TypeGenerator } from './type-generator.js';
8
+ export { Validator } from './validator.js';
9
+ export { ensureTableRowsValid } from './jsonl-migration.js';
10
+ export type { TableValidationOptions } from './jsonl-migration.js';
11
+ export { detectRuntime, RUNTIME } from './runtime.js';
12
+ export type { RuntimeEnvironment } from './runtime.js';
13
+ export type { SQLiteDatabase, SQLiteStatement } from './sqlite-adapter.js';
14
+ export type { TypeGeneratorOptions } from './type-generator.js';
15
+ export type { ValidatorOptions, ValidationResult, ValidationErrorDetail } from './validator.js';
16
+ export type {
17
+ TableSchema,
18
+ ColumnDefinition,
19
+ DatabaseConfig,
20
+ TableConfig,
21
+ JsonValue,
22
+ JsonObject,
23
+ JsonArray,
24
+ StandardSchema,
25
+ StandardSchemaResult,
26
+ StandardSchemaIssue,
27
+ ValidationError,
28
+ InferInput,
29
+ InferOutput,
30
+ Table,
31
+ TableDefs,
32
+ ForeignKeyDefinition,
33
+ IndexDefinition,
34
+ } from './types.js';
35
+ export type { BiDirectionalSchema, SchemaOptions } from './schema.js';
@@ -0,0 +1,76 @@
1
+ import { join } from 'node:path';
2
+ import type { JsonObject } from './types.js';
3
+ import { JsonlReader } from './jsonl-reader.js';
4
+ import { LinesDB } from './database.js';
5
+
6
+ export interface TableValidationOptions {
7
+ dataDir: string;
8
+ tableName: string;
9
+ rows: JsonObject[];
10
+ }
11
+
12
+ /**
13
+ * Validate a table by temporarily supplying in-memory rows while reusing the existing LinesDB validation pipeline.
14
+ * If validation fails, the underlying LinesDB error is rethrown so callers can inspect validation details.
15
+ */
16
+ export async function ensureTableRowsValid(options: TableValidationOptions): Promise<void> {
17
+ console.log('[ensureTableRowsValid] START');
18
+ console.log('[ensureTableRowsValid] dataDir:', options.dataDir);
19
+ console.log('[ensureTableRowsValid] tableName:', options.tableName);
20
+ console.log('[ensureTableRowsValid] rows count:', options.rows.length);
21
+
22
+ const tablePath = join(options.dataDir, `${options.tableName}.jsonl`);
23
+ const overrides = new Map<string, JsonObject[]>([[tablePath, options.rows]]);
24
+ console.log('[ensureTableRowsValid] tablePath:', tablePath);
25
+
26
+ let capturedError: Error | null = null;
27
+
28
+ // Intercept console.warn to capture validation errors
29
+ const originalWarn = console.warn;
30
+ const warnMessages: string[] = [];
31
+ console.warn = (...args: any[]) => {
32
+ const message = args.join(' ');
33
+ console.log('[ensureTableRowsValid] Captured warn:', message);
34
+ warnMessages.push(message);
35
+ // Check if this is a validation error for our table
36
+ if (
37
+ message.includes(`Failed to load table '${options.tableName}'`) &&
38
+ message.includes('Validation failed')
39
+ ) {
40
+ // Extract the original error from the warn message
41
+ capturedError = new Error(message);
42
+ console.log('[ensureTableRowsValid] Captured validation error!');
43
+ }
44
+ };
45
+
46
+ try {
47
+ console.log('[ensureTableRowsValid] Calling JsonlReader.withOverrides');
48
+ await JsonlReader.withOverrides(overrides, async () => {
49
+ console.log('[ensureTableRowsValid] Inside withOverrides callback');
50
+ const db = LinesDB.create({ dataDir: options.dataDir });
51
+ console.log('[ensureTableRowsValid] LinesDB created');
52
+ try {
53
+ console.log('[ensureTableRowsValid] Calling db.initialize()');
54
+ await db.initialize();
55
+ console.log('[ensureTableRowsValid] db.initialize() completed');
56
+ } finally {
57
+ console.log('[ensureTableRowsValid] Calling db.close()');
58
+ await db.close();
59
+ }
60
+ });
61
+ console.log('[ensureTableRowsValid] withOverrides completed');
62
+ } finally {
63
+ // Restore original console.warn
64
+ console.warn = originalWarn;
65
+ }
66
+
67
+ console.log('[ensureTableRowsValid] Warnings captured:', warnMessages.length);
68
+ console.log('[ensureTableRowsValid] capturedError:', capturedError ? 'YES' : 'NO');
69
+
70
+ if (capturedError) {
71
+ console.log('[ensureTableRowsValid] Throwing captured error');
72
+ throw capturedError;
73
+ }
74
+
75
+ console.log('[ensureTableRowsValid] END (success)');
76
+ }
@@ -0,0 +1,168 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { JsonlReader } from './jsonl-reader.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('JsonlReader', () => {
8
+ let testDir: string;
9
+ let testFilePath: string;
10
+
11
+ beforeEach(async () => {
12
+ testDir = join(tmpdir(), `jsonl-test-${Date.now()}`);
13
+ await mkdir(testDir, { recursive: true });
14
+ testFilePath = join(testDir, 'test.jsonl');
15
+ });
16
+
17
+ afterEach(async () => {
18
+ await rm(testDir, { recursive: true, force: true });
19
+ });
20
+
21
+ describe('read', () => {
22
+ it('should read and parse JSONL file', async () => {
23
+ const content = '{"id": 1, "name": "Alice"}\n{"id": 2, "name": "Bob"}\n';
24
+ await writeFile(testFilePath, content);
25
+
26
+ const result = await JsonlReader.read(testFilePath);
27
+
28
+ expect(result).toHaveLength(2);
29
+ expect(result[0]).toEqual({ id: 1, name: 'Alice' });
30
+ expect(result[1]).toEqual({ id: 2, name: 'Bob' });
31
+ });
32
+
33
+ it('should handle empty lines', async () => {
34
+ const content = '{"id": 1}\n\n{"id": 2}\n';
35
+ await writeFile(testFilePath, content);
36
+
37
+ const result = await JsonlReader.read(testFilePath);
38
+
39
+ expect(result).toHaveLength(2);
40
+ expect(result[0]).toEqual({ id: 1 });
41
+ expect(result[1]).toEqual({ id: 2 });
42
+ });
43
+
44
+ it('should handle trailing newlines', async () => {
45
+ const content = '{"id": 1}\n{"id": 2}\n\n\n';
46
+ await writeFile(testFilePath, content);
47
+
48
+ const result = await JsonlReader.read(testFilePath);
49
+
50
+ expect(result).toHaveLength(2);
51
+ });
52
+
53
+ it('should throw error for invalid JSON', async () => {
54
+ const content = '{"id": 1}\n{invalid json}\n';
55
+ await writeFile(testFilePath, content);
56
+
57
+ await expect(JsonlReader.read(testFilePath)).rejects.toThrow('Failed to parse JSON line');
58
+ });
59
+ });
60
+
61
+ describe('inferSchema', () => {
62
+ it('should infer schema with basic types', () => {
63
+ const data = [
64
+ { id: 1, name: 'Alice', age: 30, active: true },
65
+ { id: 2, name: 'Bob', age: 25, active: false },
66
+ ];
67
+
68
+ const schema = JsonlReader.inferSchema('users', data);
69
+
70
+ expect(schema.name).toBe('users');
71
+ expect(schema.columns).toHaveLength(4);
72
+
73
+ const idColumn = schema.columns.find((col) => col.name === 'id');
74
+ expect(idColumn).toEqual({
75
+ name: 'id',
76
+ type: 'INTEGER',
77
+ notNull: true,
78
+ primaryKey: true,
79
+ });
80
+
81
+ const nameColumn = schema.columns.find((col) => col.name === 'name');
82
+ expect(nameColumn?.type).toBe('TEXT');
83
+
84
+ const ageColumn = schema.columns.find((col) => col.name === 'age');
85
+ expect(ageColumn?.type).toBe('INTEGER');
86
+
87
+ const activeColumn = schema.columns.find((col) => col.name === 'active');
88
+ expect(activeColumn?.type).toBe('INTEGER'); // Boolean stored as INTEGER
89
+ });
90
+
91
+ it('should infer JSON type for objects', () => {
92
+ const data1 = { id: 1, metadata: { key: 'value' } };
93
+ const data2 = { id: 2, metadata: { foo: 'bar' } };
94
+
95
+ const schema = JsonlReader.inferSchema('records', [data1, data2]);
96
+
97
+ const metadataColumn = schema.columns.find((col) => col.name === 'metadata');
98
+ expect(metadataColumn?.type).toBe('JSON');
99
+ });
100
+
101
+ it('should infer JSON type for arrays', () => {
102
+ const data = [
103
+ { id: 1, tags: ['a', 'b', 'c'] },
104
+ { id: 2, tags: ['x', 'y'] },
105
+ ];
106
+
107
+ const schema = JsonlReader.inferSchema('records', data);
108
+
109
+ const tagsColumn = schema.columns.find((col) => col.name === 'tags');
110
+ expect(tagsColumn?.type).toBe('JSON');
111
+ });
112
+
113
+ it('should handle nullable columns', () => {
114
+ const data = [
115
+ { id: 1, name: 'Alice', email: null },
116
+ { id: 2, name: 'Bob', email: 'bob@example.com' },
117
+ ];
118
+
119
+ const schema = JsonlReader.inferSchema('users', data);
120
+
121
+ const emailColumn = schema.columns.find((col) => col.name === 'email');
122
+ expect(emailColumn?.type).toBe('TEXT');
123
+ expect(emailColumn?.notNull).toBe(false);
124
+ });
125
+
126
+ it('should handle mixed numeric types as REAL', () => {
127
+ const data = [
128
+ { id: 1, value: 10 },
129
+ { id: 2, value: 10.5 },
130
+ ];
131
+
132
+ const schema = JsonlReader.inferSchema('records', data);
133
+
134
+ const valueColumn = schema.columns.find((col) => col.name === 'value');
135
+ expect(valueColumn?.type).toBe('REAL');
136
+ });
137
+
138
+ it('should use TEXT as fallback for mixed types', () => {
139
+ const data = [
140
+ { id: 1, mixed: 'text' },
141
+ { id: 2, mixed: 123 },
142
+ ];
143
+
144
+ const schema = JsonlReader.inferSchema('records', data);
145
+
146
+ const mixedColumn = schema.columns.find((col) => col.name === 'mixed');
147
+ expect(mixedColumn?.type).toBe('TEXT');
148
+ });
149
+
150
+ it('should throw error for empty data', () => {
151
+ expect(() => JsonlReader.inferSchema('empty', [])).toThrow(
152
+ 'Cannot infer schema from empty data',
153
+ );
154
+ });
155
+
156
+ it('should handle REAL numbers', () => {
157
+ const data = [
158
+ { id: 1, price: 99.99 },
159
+ { id: 2, price: 149.5 },
160
+ ];
161
+
162
+ const schema = JsonlReader.inferSchema('products', data);
163
+
164
+ const priceColumn = schema.columns.find((col) => col.name === 'price');
165
+ expect(priceColumn?.type).toBe('REAL');
166
+ });
167
+ });
168
+ });