@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,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
+ }