@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.
- package/LICENSE +21 -0
- package/bin/cli.js +1373 -0
- package/dist/index.cjs +1212 -0
- package/dist/index.d.cts +486 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.ts +486 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1181 -0
- package/dist/index.js.map +1 -0
- package/package.json +64 -0
- package/src/cli.ts +333 -0
- package/src/database.test.ts +493 -0
- package/src/database.ts +1025 -0
- package/src/directory-scanner.test.ts +91 -0
- package/src/directory-scanner.ts +38 -0
- package/src/error-formatter.ts +166 -0
- package/src/index.ts +35 -0
- package/src/jsonl-migration.ts +76 -0
- package/src/jsonl-reader.test.ts +168 -0
- package/src/jsonl-reader.ts +135 -0
- package/src/jsonl-writer.test.ts +101 -0
- package/src/jsonl-writer.ts +33 -0
- package/src/runtime.ts +34 -0
- package/src/schema-loader.test.ts +136 -0
- package/src/schema-loader.ts +64 -0
- package/src/schema.ts +135 -0
- package/src/sqlite-adapter.ts +99 -0
- package/src/type-generator.ts +201 -0
- package/src/types.ts +99 -0
- package/src/validator.test.ts +337 -0
- package/src/validator.ts +207 -0
- package/tsconfig.json +20 -0
- package/tsconfig.test.json +8 -0
- package/tsdown.config.ts +26 -0
- package/vitest.config.ts +9 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { normalize } from 'node:path';
|
|
3
|
+
import type { JsonObject, ColumnDefinition, TableSchema } from './types.js';
|
|
4
|
+
|
|
5
|
+
export class JsonlReader {
|
|
6
|
+
private static overrides: Map<string, JsonObject[]> | null = null;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Temporarily override the data returned for specific JSONL files.
|
|
10
|
+
* Useful for scenarios like migration validation where in-memory data should be used.
|
|
11
|
+
*/
|
|
12
|
+
static async withOverrides<T>(
|
|
13
|
+
overrides: Map<string, JsonObject[]>,
|
|
14
|
+
fn: () => Promise<T>,
|
|
15
|
+
): Promise<T> {
|
|
16
|
+
const normalized = new Map<string, JsonObject[]>();
|
|
17
|
+
for (const [filePath, rows] of overrides) {
|
|
18
|
+
normalized.set(normalize(filePath), rows);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const previousOverrides = this.overrides;
|
|
22
|
+
this.overrides = normalized;
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
return await fn();
|
|
26
|
+
} finally {
|
|
27
|
+
this.overrides = previousOverrides;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Read JSONL file and parse each line as JSON
|
|
33
|
+
*/
|
|
34
|
+
static async read(filePath: string): Promise<JsonObject[]> {
|
|
35
|
+
const overrideRows = this.overrides?.get(normalize(filePath));
|
|
36
|
+
if (overrideRows) {
|
|
37
|
+
// Return clones to avoid accidental mutations from consumers
|
|
38
|
+
return overrideRows.map((row) => JSON.parse(JSON.stringify(row)) as JsonObject);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const content = await readFile(filePath, 'utf-8');
|
|
42
|
+
const lines = content.trim().split('\n');
|
|
43
|
+
|
|
44
|
+
return lines
|
|
45
|
+
.filter((line) => line.trim().length > 0)
|
|
46
|
+
.map((line) => {
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(line) as JsonObject;
|
|
49
|
+
} catch (error) {
|
|
50
|
+
throw new Error(`Failed to parse JSON line: ${line}`, { cause: error });
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Infer schema from JSONL data
|
|
57
|
+
*/
|
|
58
|
+
static inferSchema(tableName: string, data: JsonObject[]): TableSchema {
|
|
59
|
+
if (data.length === 0) {
|
|
60
|
+
throw new Error('Cannot infer schema from empty data');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const columnTypes = new Map<string, Set<string>>();
|
|
64
|
+
const booleanColumns = new Set<string>();
|
|
65
|
+
const nonBooleanColumns = new Set<string>();
|
|
66
|
+
|
|
67
|
+
// Collect all column names and their types
|
|
68
|
+
for (const row of data) {
|
|
69
|
+
for (const [key, value] of Object.entries(row)) {
|
|
70
|
+
if (!columnTypes.has(key)) {
|
|
71
|
+
columnTypes.set(key, new Set());
|
|
72
|
+
}
|
|
73
|
+
columnTypes.get(key)!.add(this.inferType(value));
|
|
74
|
+
|
|
75
|
+
if (typeof value === 'boolean') {
|
|
76
|
+
booleanColumns.add(key);
|
|
77
|
+
} else if (value !== null) {
|
|
78
|
+
nonBooleanColumns.add(key);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Convert to column definitions
|
|
84
|
+
const columns: ColumnDefinition[] = [];
|
|
85
|
+
for (const [columnName, types] of columnTypes.entries()) {
|
|
86
|
+
const typeArray = Array.from(types);
|
|
87
|
+
|
|
88
|
+
// If multiple types exist, prefer TEXT as a safe fallback
|
|
89
|
+
let sqlType: ColumnDefinition['type'] = 'TEXT';
|
|
90
|
+
|
|
91
|
+
if (typeArray.length === 1) {
|
|
92
|
+
sqlType = typeArray[0] as ColumnDefinition['type'];
|
|
93
|
+
} else if (typeArray.every((t) => t === 'INTEGER' || t === 'REAL')) {
|
|
94
|
+
sqlType = 'REAL'; // Use REAL if we have mixed numeric types
|
|
95
|
+
} else if (!typeArray.includes('NULL')) {
|
|
96
|
+
// If there are multiple non-null types, use TEXT
|
|
97
|
+
sqlType = 'TEXT';
|
|
98
|
+
} else if (typeArray.length === 2 && typeArray.includes('NULL')) {
|
|
99
|
+
// If one type + NULL, use the non-null type
|
|
100
|
+
sqlType = typeArray.find((t) => t !== 'NULL') as ColumnDefinition['type'];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const isBooleanColumn = booleanColumns.has(columnName) && !nonBooleanColumns.has(columnName);
|
|
104
|
+
|
|
105
|
+
columns.push({
|
|
106
|
+
name: columnName,
|
|
107
|
+
type: sqlType,
|
|
108
|
+
notNull: !typeArray.includes('NULL'),
|
|
109
|
+
valueType: isBooleanColumn ? 'boolean' : undefined,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// If there's an 'id' column, make it primary key
|
|
114
|
+
const idColumn = columns.find((col) => col.name === 'id');
|
|
115
|
+
if (idColumn) {
|
|
116
|
+
idColumn.primaryKey = true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
name: tableName,
|
|
121
|
+
columns,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private static inferType(value: unknown): string {
|
|
126
|
+
if (value === null) return 'NULL';
|
|
127
|
+
if (typeof value === 'number') {
|
|
128
|
+
return Number.isInteger(value) ? 'INTEGER' : 'REAL';
|
|
129
|
+
}
|
|
130
|
+
if (typeof value === 'string') return 'TEXT';
|
|
131
|
+
if (typeof value === 'boolean') return 'INTEGER'; // SQLite stores booleans as integers
|
|
132
|
+
if (typeof value === 'object') return 'JSON'; // Store objects/arrays as JSON
|
|
133
|
+
return 'TEXT';
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { JsonlWriter } from './jsonl-writer.js';
|
|
3
|
+
import { readFile, mkdir, rm } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
|
|
7
|
+
describe('JsonlWriter', () => {
|
|
8
|
+
let testDir: string;
|
|
9
|
+
let testFilePath: string;
|
|
10
|
+
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
testDir = join(tmpdir(), `jsonl-writer-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('write', () => {
|
|
22
|
+
it('should write data to JSONL file', async () => {
|
|
23
|
+
const data = [
|
|
24
|
+
{ id: 1, name: 'Alice' },
|
|
25
|
+
{ id: 2, name: 'Bob' },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
await JsonlWriter.write(testFilePath, data);
|
|
29
|
+
|
|
30
|
+
const content = await readFile(testFilePath, 'utf-8');
|
|
31
|
+
expect(content).toBe('{"id":1,"name":"Alice"}\n{"id":2,"name":"Bob"}\n');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should write empty array', async () => {
|
|
35
|
+
await JsonlWriter.write(testFilePath, []);
|
|
36
|
+
|
|
37
|
+
const content = await readFile(testFilePath, 'utf-8');
|
|
38
|
+
expect(content).toBe('\n');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should write complex objects', async () => {
|
|
42
|
+
const data1 = { id: 1, metadata: { key: 'value' }, tags: ['a', 'b'] };
|
|
43
|
+
const data2 = { id: 2, active: true, score: 99.5 };
|
|
44
|
+
|
|
45
|
+
await JsonlWriter.write(testFilePath, [data1, data2]);
|
|
46
|
+
|
|
47
|
+
const content = await readFile(testFilePath, 'utf-8');
|
|
48
|
+
const lines = content.trim().split('\n');
|
|
49
|
+
|
|
50
|
+
expect(lines).toHaveLength(2);
|
|
51
|
+
expect(JSON.parse(lines[0])).toEqual(data1);
|
|
52
|
+
expect(JSON.parse(lines[1])).toEqual(data2);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should overwrite existing file', async () => {
|
|
56
|
+
await JsonlWriter.write(testFilePath, [{ id: 1 }]);
|
|
57
|
+
await JsonlWriter.write(testFilePath, [{ id: 2 }]);
|
|
58
|
+
|
|
59
|
+
const content = await readFile(testFilePath, 'utf-8');
|
|
60
|
+
expect(content).toBe('{"id":2}\n');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('append', () => {
|
|
65
|
+
it('should append data to existing file', async () => {
|
|
66
|
+
await JsonlWriter.write(testFilePath, [{ id: 1, name: 'Alice' }]);
|
|
67
|
+
await JsonlWriter.append(testFilePath, [{ id: 2, name: 'Bob' }]);
|
|
68
|
+
|
|
69
|
+
const content = await readFile(testFilePath, 'utf-8');
|
|
70
|
+
expect(content).toBe('{"id":1,"name":"Alice"}\n{"id":2,"name":"Bob"}\n');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should create file if it does not exist', async () => {
|
|
74
|
+
await JsonlWriter.append(testFilePath, [{ id: 1, name: 'Alice' }]);
|
|
75
|
+
|
|
76
|
+
const content = await readFile(testFilePath, 'utf-8');
|
|
77
|
+
expect(content).toBe('{"id":1,"name":"Alice"}\n');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should append multiple records', async () => {
|
|
81
|
+
await JsonlWriter.write(testFilePath, [{ id: 1 }]);
|
|
82
|
+
await JsonlWriter.append(testFilePath, [{ id: 2 }, { id: 3 }, { id: 4 }]);
|
|
83
|
+
|
|
84
|
+
const content = await readFile(testFilePath, 'utf-8');
|
|
85
|
+
const lines = content.trim().split('\n');
|
|
86
|
+
|
|
87
|
+
expect(lines).toHaveLength(4);
|
|
88
|
+
expect(JSON.parse(lines[0])).toEqual({ id: 1 });
|
|
89
|
+
expect(JSON.parse(lines[3])).toEqual({ id: 4 });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should handle empty array append', async () => {
|
|
93
|
+
await JsonlWriter.write(testFilePath, [{ id: 1 }]);
|
|
94
|
+
await JsonlWriter.append(testFilePath, []);
|
|
95
|
+
|
|
96
|
+
const content = await readFile(testFilePath, 'utf-8');
|
|
97
|
+
// Appending empty array adds just a newline
|
|
98
|
+
expect(content).toBe('{"id":1}\n\n');
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { writeFile } from 'node:fs/promises';
|
|
2
|
+
import type { JsonObject } from './types.js';
|
|
3
|
+
|
|
4
|
+
export class JsonlWriter {
|
|
5
|
+
/**
|
|
6
|
+
* Write data to JSONL file
|
|
7
|
+
*/
|
|
8
|
+
static async write(filePath: string, data: JsonObject[]): Promise<void> {
|
|
9
|
+
const lines = data.map((obj) => JSON.stringify(obj)).join('\n');
|
|
10
|
+
await writeFile(filePath, lines + '\n', 'utf-8');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Append data to JSONL file
|
|
15
|
+
*/
|
|
16
|
+
static async append(filePath: string, data: JsonObject[]): Promise<void> {
|
|
17
|
+
const { readFile, writeFile } = await import('node:fs/promises');
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const existing = await readFile(filePath, 'utf-8');
|
|
21
|
+
const lines = data.map((obj) => JSON.stringify(obj)).join('\n');
|
|
22
|
+
const newContent = existing.trim() + '\n' + lines + '\n';
|
|
23
|
+
await writeFile(filePath, newContent, 'utf-8');
|
|
24
|
+
} catch (error) {
|
|
25
|
+
// If file doesn't exist, just write the data
|
|
26
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
27
|
+
await this.write(filePath, data);
|
|
28
|
+
} else {
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime detection utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type RuntimeEnvironment = 'node' | 'bun' | 'deno' | 'unknown';
|
|
6
|
+
|
|
7
|
+
export function detectRuntime(): RuntimeEnvironment {
|
|
8
|
+
// Check for Bun
|
|
9
|
+
if (
|
|
10
|
+
typeof globalThis !== 'undefined' &&
|
|
11
|
+
'Bun' in globalThis &&
|
|
12
|
+
typeof (globalThis as { Bun?: unknown }).Bun !== 'undefined'
|
|
13
|
+
) {
|
|
14
|
+
return 'bun';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Check for Deno
|
|
18
|
+
if (
|
|
19
|
+
typeof globalThis !== 'undefined' &&
|
|
20
|
+
'Deno' in globalThis &&
|
|
21
|
+
typeof (globalThis as { Deno?: unknown }).Deno !== 'undefined'
|
|
22
|
+
) {
|
|
23
|
+
return 'deno';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Check for Node.js
|
|
27
|
+
if (typeof process !== 'undefined' && process.versions && process.versions.node) {
|
|
28
|
+
return 'node';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return 'unknown';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const RUNTIME = detectRuntime();
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { SchemaLoader } from './schema-loader.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('SchemaLoader', () => {
|
|
8
|
+
let testDir: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
testDir = join(tmpdir(), `schema-loader-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('loadSchema', () => {
|
|
20
|
+
it('should load schema from .ts file', async () => {
|
|
21
|
+
const jsonlPath = join(testDir, 'users.jsonl');
|
|
22
|
+
const schemaPath = join(testDir, 'users.schema.ts');
|
|
23
|
+
|
|
24
|
+
await writeFile(jsonlPath, '{"id":1}\n');
|
|
25
|
+
await writeFile(
|
|
26
|
+
schemaPath,
|
|
27
|
+
`
|
|
28
|
+
export const schema = {
|
|
29
|
+
'~standard': {
|
|
30
|
+
version: 1,
|
|
31
|
+
vendor: 'test',
|
|
32
|
+
validate: () => ({ value: {} })
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
`,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const schema = await SchemaLoader.loadSchema(jsonlPath);
|
|
39
|
+
|
|
40
|
+
expect(schema).toBeDefined();
|
|
41
|
+
expect(schema['~standard'].version).toBe(1);
|
|
42
|
+
expect(schema['~standard'].vendor).toBe('test');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should load default export', async () => {
|
|
46
|
+
const jsonlPath = join(testDir, 'users.jsonl');
|
|
47
|
+
const schemaPath = join(testDir, 'users.schema.ts');
|
|
48
|
+
|
|
49
|
+
await writeFile(jsonlPath, '{"id":1}\n');
|
|
50
|
+
await writeFile(
|
|
51
|
+
schemaPath,
|
|
52
|
+
`
|
|
53
|
+
export default {
|
|
54
|
+
'~standard': {
|
|
55
|
+
version: 1,
|
|
56
|
+
vendor: 'default-export',
|
|
57
|
+
validate: () => ({ value: {} })
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
`,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const schema = await SchemaLoader.loadSchema(jsonlPath);
|
|
64
|
+
|
|
65
|
+
expect(schema['~standard'].vendor).toBe('default-export');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should throw when no schema file exists', async () => {
|
|
69
|
+
const jsonlPath = join(testDir, 'users.jsonl');
|
|
70
|
+
await writeFile(jsonlPath, '{"id":1}\n');
|
|
71
|
+
|
|
72
|
+
await expect(SchemaLoader.loadSchema(jsonlPath)).rejects.toThrow(/Schema file not found/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should throw for invalid schema export', async () => {
|
|
76
|
+
const jsonlPath = join(testDir, 'users.jsonl');
|
|
77
|
+
const schemaPath = join(testDir, 'users.schema.ts');
|
|
78
|
+
|
|
79
|
+
await writeFile(jsonlPath, '{"id":1}\n');
|
|
80
|
+
await writeFile(
|
|
81
|
+
schemaPath,
|
|
82
|
+
`
|
|
83
|
+
export const schema = {
|
|
84
|
+
notAStandardSchema: true
|
|
85
|
+
};
|
|
86
|
+
`,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
await expect(SchemaLoader.loadSchema(jsonlPath)).rejects.toThrow(
|
|
90
|
+
/does not export a valid StandardSchema/,
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should validate StandardSchema structure', async () => {
|
|
95
|
+
const jsonlPath = join(testDir, 'test.jsonl');
|
|
96
|
+
const schemaPath = join(testDir, 'test.schema.ts');
|
|
97
|
+
|
|
98
|
+
await writeFile(jsonlPath, '{"id":1}\n');
|
|
99
|
+
|
|
100
|
+
// Missing validate function
|
|
101
|
+
await writeFile(
|
|
102
|
+
schemaPath,
|
|
103
|
+
`
|
|
104
|
+
export const schema = {
|
|
105
|
+
'~standard': {
|
|
106
|
+
version: 1,
|
|
107
|
+
vendor: 'test'
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
`,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
await expect(SchemaLoader.loadSchema(jsonlPath)).rejects.toThrow(
|
|
114
|
+
/does not export a valid StandardSchema/,
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
// Wrong version
|
|
118
|
+
await writeFile(
|
|
119
|
+
schemaPath,
|
|
120
|
+
`
|
|
121
|
+
export const schema = {
|
|
122
|
+
'~standard': {
|
|
123
|
+
version: 2,
|
|
124
|
+
vendor: 'test',
|
|
125
|
+
validate: () => ({ value: {} })
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
`,
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
await expect(SchemaLoader.loadSchema(jsonlPath)).rejects.toThrow(
|
|
132
|
+
/does not export a valid StandardSchema/,
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { pathToFileURL } from 'node:url';
|
|
2
|
+
import { access } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join, basename } from 'node:path';
|
|
4
|
+
import type { StandardSchema } from './types.js';
|
|
5
|
+
|
|
6
|
+
export class SchemaLoader {
|
|
7
|
+
/**
|
|
8
|
+
* Load a validation schema file for a table
|
|
9
|
+
* Requires ${tableName}.schema.ts to exist alongside the JSONL file
|
|
10
|
+
*/
|
|
11
|
+
static async loadSchema(jsonlPath: string): Promise<StandardSchema> {
|
|
12
|
+
const dir = dirname(jsonlPath);
|
|
13
|
+
const tableName = basename(jsonlPath, '.jsonl');
|
|
14
|
+
const schemaPath = join(dir, `${tableName}.schema.ts`);
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
await access(schemaPath);
|
|
18
|
+
} catch (error) {
|
|
19
|
+
throw new Error(`Schema file not found for table '${tableName}'. Expected: ${schemaPath}`, {
|
|
20
|
+
cause: error instanceof Error ? error : undefined,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const schemaUrl = pathToFileURL(schemaPath).href;
|
|
26
|
+
// Add cache busting query parameter to force reload on each import
|
|
27
|
+
// This ensures schema changes are picked up immediately
|
|
28
|
+
const cacheBustedUrl = `${schemaUrl}?t=${Date.now()}`;
|
|
29
|
+
const module = await import(cacheBustedUrl);
|
|
30
|
+
const schema = module.default || module.schema;
|
|
31
|
+
|
|
32
|
+
if (schema && this.isStandardSchema(schema)) {
|
|
33
|
+
return schema;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
throw new Error(`Schema file ${schemaPath} does not export a valid StandardSchema`);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`Failed to load schema for table '${tableName}' from ${schemaPath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
40
|
+
{ cause: error instanceof Error ? error : undefined },
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Check if an object implements the StandardSchema interface
|
|
47
|
+
*/
|
|
48
|
+
private static isStandardSchema(obj: unknown): obj is StandardSchema {
|
|
49
|
+
if (!obj || typeof obj !== 'object') return false;
|
|
50
|
+
|
|
51
|
+
const schema = obj as Record<string, unknown>;
|
|
52
|
+
const standard = schema['~standard'];
|
|
53
|
+
|
|
54
|
+
if (!standard || typeof standard !== 'object') return false;
|
|
55
|
+
|
|
56
|
+
const standardObj = standard as Record<string, unknown>;
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
standardObj.version === 1 &&
|
|
60
|
+
typeof standardObj.vendor === 'string' &&
|
|
61
|
+
typeof standardObj.validate === 'function'
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { StandardSchema, Table, ForeignKeyDefinition, IndexDefinition } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Schema options for defining constraints and indexes
|
|
5
|
+
*/
|
|
6
|
+
export interface SchemaOptions {
|
|
7
|
+
/**
|
|
8
|
+
* Primary key columns
|
|
9
|
+
*/
|
|
10
|
+
primaryKey?: string[];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Foreign key constraints
|
|
14
|
+
*/
|
|
15
|
+
foreignKeys?: ForeignKeyDefinition[];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Indexes to create
|
|
19
|
+
*/
|
|
20
|
+
indexes?: IndexDefinition[];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Backward transformation from Output to Input
|
|
24
|
+
* Required when Input and Output types differ (e.g., with transformations)
|
|
25
|
+
*/
|
|
26
|
+
backward?: (output: Table) => Table;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* BiDirectional Schema interface
|
|
31
|
+
* Extends StandardSchema with optional backward transformation and schema metadata
|
|
32
|
+
*/
|
|
33
|
+
export interface BiDirectionalSchema<Input extends Table = Table, Output extends Table = Input>
|
|
34
|
+
extends StandardSchema<Input, Output> {
|
|
35
|
+
/**
|
|
36
|
+
* Backward transformation from Output to Input
|
|
37
|
+
* Required when Input and Output types differ (e.g., with transformations)
|
|
38
|
+
*/
|
|
39
|
+
backward?: (output: Output) => Input;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Primary key columns
|
|
43
|
+
*/
|
|
44
|
+
primaryKey?: string[];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Foreign key constraints
|
|
48
|
+
*/
|
|
49
|
+
foreignKeys?: ForeignKeyDefinition[];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Indexes to create
|
|
53
|
+
*/
|
|
54
|
+
indexes?: IndexDefinition[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Define a bidirectional schema with optional backward transformation
|
|
59
|
+
*
|
|
60
|
+
* @param schema - Standard Schema for validation
|
|
61
|
+
* @param optionsOrBackward - Optional SchemaOptions object or backward transformation function (Output → Input)
|
|
62
|
+
* Required when schema performs transformations
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* // No transformation - backward not needed
|
|
66
|
+
* const schema = defineSchema(
|
|
67
|
+
* v.object({ id: v.number(), name: v.string() })
|
|
68
|
+
* );
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* // With transformation - backward recommended (legacy)
|
|
72
|
+
* const schema = defineSchema(
|
|
73
|
+
* v.pipe(v.string(), v.transform(Number)),
|
|
74
|
+
* (num) => String(num) // backward: number → string
|
|
75
|
+
* );
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* // With primary key and foreign key
|
|
79
|
+
* const schema = defineSchema(
|
|
80
|
+
* v.object({ id: v.number(), customerId: v.number() }),
|
|
81
|
+
* {
|
|
82
|
+
* primaryKey: ['id'],
|
|
83
|
+
* foreignKeys: [
|
|
84
|
+
* { columns: ['customerId'], references: { table: 'users', columns: ['id'] } }
|
|
85
|
+
* ]
|
|
86
|
+
* }
|
|
87
|
+
* );
|
|
88
|
+
*/
|
|
89
|
+
export function defineSchema<Input extends Table, Output extends Table>(
|
|
90
|
+
schema: StandardSchema<Input, Output>,
|
|
91
|
+
optionsOrBackward?: SchemaOptions | ((output: Output) => Input),
|
|
92
|
+
): BiDirectionalSchema<Input, Output> {
|
|
93
|
+
// Create a new object that extends the schema
|
|
94
|
+
const bidirectionalSchema = Object.create(schema) as BiDirectionalSchema<Input, Output>;
|
|
95
|
+
|
|
96
|
+
// Handle options or backward function
|
|
97
|
+
if (optionsOrBackward) {
|
|
98
|
+
if (typeof optionsOrBackward === 'function') {
|
|
99
|
+
// Legacy: backward function only
|
|
100
|
+
bidirectionalSchema.backward = optionsOrBackward;
|
|
101
|
+
} else {
|
|
102
|
+
// New: options object
|
|
103
|
+
if (optionsOrBackward.backward) {
|
|
104
|
+
bidirectionalSchema.backward = optionsOrBackward.backward as (output: Output) => Input;
|
|
105
|
+
}
|
|
106
|
+
if (optionsOrBackward.primaryKey) {
|
|
107
|
+
bidirectionalSchema.primaryKey = optionsOrBackward.primaryKey;
|
|
108
|
+
}
|
|
109
|
+
if (optionsOrBackward.foreignKeys) {
|
|
110
|
+
bidirectionalSchema.foreignKeys = optionsOrBackward.foreignKeys;
|
|
111
|
+
}
|
|
112
|
+
if (optionsOrBackward.indexes) {
|
|
113
|
+
bidirectionalSchema.indexes = optionsOrBackward.indexes;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Copy '~standard' property
|
|
119
|
+
Object.defineProperty(bidirectionalSchema, '~standard', {
|
|
120
|
+
value: schema['~standard'],
|
|
121
|
+
enumerable: true,
|
|
122
|
+
configurable: true,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
return bidirectionalSchema;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Check if a schema has backward transformation
|
|
130
|
+
*/
|
|
131
|
+
export function hasBackward<Input extends Table, Output extends Table>(
|
|
132
|
+
schema: StandardSchema<Input, Output>,
|
|
133
|
+
): schema is BiDirectionalSchema<Input, Output> {
|
|
134
|
+
return 'backward' in schema && typeof (schema as { backward?: unknown }).backward === 'function';
|
|
135
|
+
}
|