@toiroakr/lines-db 0.4.1 → 0.6.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/src/database.ts CHANGED
@@ -10,11 +10,13 @@ import type {
10
10
  JsonObject,
11
11
  TableConfig,
12
12
  StandardSchema,
13
- StandardSchemaIssue,
14
13
  ValidationError,
15
14
  Table,
16
15
  TableDefs,
17
16
  WhereCondition,
17
+ ValidationResult,
18
+ ValidationErrorDetail,
19
+ ForeignKeyDefinition,
18
20
  } from './types.js';
19
21
  import type { BiDirectionalSchema } from './schema.js';
20
22
 
@@ -39,35 +41,68 @@ export class LinesDB<Tables extends TableDefs> {
39
41
  }
40
42
 
41
43
  /**
42
- * Initialize database by loading all JSONL files
44
+ * Initialize database by loading all JSONL files or a specific table
43
45
  * Uses dependency resolution to ensure foreign key references are loaded in correct order
46
+ * @param options Optional configuration for initialization
47
+ * @param options.tableName Optional table name to initialize. If not provided, initializes all tables
48
+ * @param options.detailedValidate If true, performs detailed validation by inserting rows one by one to catch constraint violations
49
+ * @param options.transform Optional transform function to apply to rows before validation (only applied to the specified tableName)
50
+ * @returns ValidationResult containing validation status, errors, and warnings
44
51
  */
45
- async initialize(): Promise<void> {
52
+ async initialize(options?: {
53
+ tableName?: string;
54
+ detailedValidate?: boolean;
55
+ transform?: (row: JsonObject) => JsonObject;
56
+ }): Promise<ValidationResult> {
57
+ const allErrors: ValidationErrorDetail[] = [];
58
+ const allWarnings: string[] = [];
59
+ const tableName = options?.tableName;
60
+ const detailedValidate = options?.detailedValidate ?? false;
61
+ const transform = options?.transform;
62
+
46
63
  // Scan directory for JSONL files
47
64
  this.tables = await DirectoryScanner.scanDirectory(this.config.dataDir);
48
65
 
66
+ // Determine which tables to load
67
+ const tablesToLoad = tableName ? [tableName] : Array.from(this.tables.keys());
68
+
69
+ // Validate that all requested tables exist BEFORE starting to load
70
+ for (const tableNameToLoad of tablesToLoad) {
71
+ if (!this.tables.has(tableNameToLoad)) {
72
+ throw new Error(
73
+ `Table '${tableNameToLoad}' not found in directory '${this.config.dataDir}'`,
74
+ );
75
+ }
76
+ }
77
+
49
78
  // Track loaded tables and tables currently being loaded (for circular dependency detection)
50
79
  const loadedTables = new Set<string>();
51
80
  const loadingTables = new Set<string>();
52
-
53
- // Load all tables with dependency resolution
54
- for (const [tableName] of this.tables) {
55
- if (!loadedTables.has(tableName)) {
56
- try {
57
- await this.loadTableWithDependencies(tableName, loadedTables, loadingTables);
58
- } catch (error) {
59
- // Log error but continue loading other tables
60
- console.warn(
61
- `Warning: Failed to load table '${tableName}':`,
62
- error instanceof Error ? error.message : String(error),
63
- );
64
- // Remove the failed table from the tables map
65
- this.tables.delete(tableName);
66
- this.schemas.delete(tableName);
67
- this.validationSchemas.delete(tableName);
68
- }
81
+ const attemptedTables = new Set<string>(); // Track all attempted tables (loaded or not)
82
+
83
+ // Load tables with dependency resolution
84
+ for (const tableNameToLoad of tablesToLoad) {
85
+ if (!attemptedTables.has(tableNameToLoad)) {
86
+ // Only apply transform to the specified table
87
+ const tableTransform = tableNameToLoad === tableName ? transform : undefined;
88
+ const { errors, warnings } = await this.loadTableWithDependencies(
89
+ tableNameToLoad,
90
+ loadedTables,
91
+ loadingTables,
92
+ attemptedTables,
93
+ detailedValidate,
94
+ tableTransform,
95
+ );
96
+ allErrors.push(...errors);
97
+ allWarnings.push(...warnings);
69
98
  }
70
99
  }
100
+
101
+ return {
102
+ valid: allErrors.length === 0,
103
+ errors: allErrors,
104
+ warnings: allWarnings,
105
+ };
71
106
  }
72
107
 
73
108
  /**
@@ -77,12 +112,21 @@ export class LinesDB<Tables extends TableDefs> {
77
112
  tableName: string,
78
113
  loadedTables: Set<string>,
79
114
  loadingTables: Set<string>,
80
- ): Promise<void> {
81
- // Skip if already loaded
82
- if (loadedTables.has(tableName)) {
83
- return;
115
+ attemptedTables: Set<string>,
116
+ detailedValidate: boolean,
117
+ transform?: (row: JsonObject) => JsonObject,
118
+ ): Promise<{ errors: ValidationErrorDetail[]; warnings: string[] }> {
119
+ const errors: ValidationErrorDetail[] = [];
120
+ const warnings: string[] = [];
121
+
122
+ // Skip if already attempted (loaded or not)
123
+ if (attemptedTables.has(tableName)) {
124
+ return { errors, warnings };
84
125
  }
85
126
 
127
+ // Mark as attempted
128
+ attemptedTables.add(tableName);
129
+
86
130
  // Check for circular dependencies
87
131
  if (loadingTables.has(tableName)) {
88
132
  throw new Error(`Circular dependency detected for table '${tableName}'`);
@@ -119,10 +163,26 @@ export class LinesDB<Tables extends TableDefs> {
119
163
  if (foreignKeys && foreignKeys.length > 0) {
120
164
  for (const fk of foreignKeys) {
121
165
  const referencedTable = fk.references.table;
122
- if (!loadedTables.has(referencedTable)) {
166
+
167
+ // Skip self-referencing foreign keys (e.g., nullable parent_id columns)
168
+ if (referencedTable === tableName) {
169
+ continue;
170
+ }
171
+
172
+ if (!attemptedTables.has(referencedTable)) {
123
173
  // Check if referenced table exists in our tables map
124
174
  if (this.tables.has(referencedTable)) {
125
- await this.loadTableWithDependencies(referencedTable, loadedTables, loadingTables);
175
+ // Dependencies should not have transform applied
176
+ const depResult = await this.loadTableWithDependencies(
177
+ referencedTable,
178
+ loadedTables,
179
+ loadingTables,
180
+ attemptedTables,
181
+ detailedValidate,
182
+ undefined,
183
+ );
184
+ errors.push(...depResult.errors);
185
+ warnings.push(...depResult.warnings);
126
186
  } else {
127
187
  throw new Error(
128
188
  `Foreign key reference to non-existent table '${referencedTable}' in table '${tableName}'`,
@@ -133,26 +193,46 @@ export class LinesDB<Tables extends TableDefs> {
133
193
  }
134
194
 
135
195
  // Now load this table
136
- const wasLoaded = await this.loadTable(tableName, tableConfig);
137
- if (wasLoaded) {
196
+ const { loaded, errors: loadErrors } = await this.loadTable(
197
+ tableName,
198
+ tableConfig,
199
+ detailedValidate,
200
+ transform,
201
+ );
202
+ errors.push(...loadErrors);
203
+
204
+ if (loaded) {
138
205
  loadedTables.add(tableName);
139
206
  } else {
140
207
  // Table was not loaded (e.g., empty data)
208
+ warnings.push(`Table '${tableName}' was not loaded (no data or skipped)`);
141
209
  this.tables.delete(tableName);
142
210
  }
143
211
  } finally {
144
212
  // Remove from loading set
145
213
  loadingTables.delete(tableName);
146
214
  }
215
+
216
+ return { errors, warnings };
147
217
  }
148
218
 
149
219
  /**
150
220
  * Load a single table from JSONL file
151
- * @returns true if table was loaded, false if skipped
221
+ * @returns Object with loaded status and validation errors
152
222
  */
153
- private async loadTable(tableName: string, config: TableConfig): Promise<boolean> {
223
+ private async loadTable(
224
+ tableName: string,
225
+ config: TableConfig,
226
+ detailedValidate: boolean,
227
+ transform?: (row: JsonObject) => JsonObject,
228
+ ): Promise<{ loaded: boolean; errors: ValidationErrorDetail[] }> {
154
229
  // Read JSONL file
155
- const data = await JsonlReader.read(config.jsonlPath);
230
+ let data = await JsonlReader.read(config.jsonlPath);
231
+
232
+ // Apply transform if provided (before validation)
233
+ if (transform) {
234
+ data = data.map((row) => transform(row));
235
+ }
156
236
 
157
237
  // Load validation schema if provided or try to auto-load
158
238
  let validationSchema = config.validationSchema;
@@ -233,29 +313,48 @@ export class LinesDB<Tables extends TableDefs> {
233
313
  }
234
314
  }
235
315
 
316
+ // Convert validation errors to ValidationErrorDetail format
317
+ const validationErrorDetails: ValidationErrorDetail[] = validationErrors.map((ve) => ({
318
+ file: config.jsonlPath,
319
+ tableName,
320
+ rowIndex: ve.rowIndex,
321
+ issues: ve.error.issues,
322
+ type: 'schema' as const,
323
+ }));
324
+
236
325
  if (validationErrors.length > 0) {
237
- const enhancedError = new Error(
238
- `Validation failed for ${validationErrors.length} row(s) in table ${tableName}`,
239
- );
240
- enhancedError.name = 'ValidationError';
241
- (enhancedError as unknown as { validationErrors: typeof validationErrors }).validationErrors =
242
- validationErrors;
243
- (enhancedError as unknown as { issues: ReadonlyArray<StandardSchemaIssue> }).issues =
244
- validationErrors[0].error.issues;
245
- throw enhancedError;
326
+ // Return errors instead of throwing
327
+ return { loaded: false, errors: validationErrorDetails };
246
328
  }
247
329
 
248
330
  // Determine schema - infer from validated data if auto-inference is enabled
249
331
  let schema: TableSchema;
332
+ let inferredSchema: TableSchema | undefined;
333
+
334
+ // Always infer schema from validated data to capture valueType information (e.g., boolean)
335
+ if (validatedData.length > 0) {
336
+ inferredSchema = JsonlReader.inferSchema(tableName, validatedData);
337
+ }
338
+
250
339
  if (config.schema) {
251
340
  schema = config.schema;
341
+ // Merge valueType information from inferred schema
342
+ if (inferredSchema) {
343
+ for (const inferredCol of inferredSchema.columns) {
344
+ const schemaCol = schema.columns.find((c) => c.name === inferredCol.name);
345
+ if (schemaCol && inferredCol.valueType && !schemaCol.valueType) {
346
+ schemaCol.valueType = inferredCol.valueType;
347
+ }
348
+ }
349
+ }
252
350
  } else if (config.autoInferSchema !== false) {
253
351
  if (validatedData.length === 0) {
254
- return false;
352
+ return { loaded: false, errors: [] };
255
353
  }
256
- // Infer schema from validated data (which may have additional fields added by validation)
257
- schema = JsonlReader.inferSchema(tableName, validatedData);
354
+ // Use inferred schema
355
+ schema = inferredSchema!;
258
356
  } else {
357
+ // Critical error - throw exception
259
358
  throw new Error(`No schema provided for table ${tableName} and autoInferSchema is disabled`);
260
359
  }
261
360
 
@@ -292,10 +391,22 @@ export class LinesDB<Tables extends TableDefs> {
292
391
  // Create table
293
392
  this.createTable(schema);
294
393
 
295
- // Insert validated data
296
- this.insertData(tableName, schema, validatedData);
394
+ // Insert validated data (with detailed validation if requested)
395
+ if (detailedValidate) {
396
+ const insertErrors = this.insertDataWithDetailedValidation(
397
+ tableName,
398
+ schema,
399
+ validatedData,
400
+ config.jsonlPath,
401
+ );
402
+ if (insertErrors.length > 0) {
403
+ return { loaded: false, errors: insertErrors };
404
+ }
405
+ } else {
406
+ this.insertData(tableName, schema, validatedData);
407
+ }
297
408
 
298
- return true;
409
+ return { loaded: true, errors: [] };
299
410
  }
300
411
 
301
412
  /**
@@ -372,9 +483,52 @@ export class LinesDB<Tables extends TableDefs> {
372
483
  }
373
484
 
374
485
  /**
375
- * Insert data into table
486
+ * Insert data into table using batch insert (multiple rows per SQL)
487
+ * SQLite has a parameter limit (default 999), so we batch rows accordingly
488
+ * Throws exception if any constraint violation occurs
376
489
  */
377
490
  private insertData(tableName: string, schema: TableSchema, data: JsonObject[]): void {
491
+ if (data.length === 0) return;
492
+
493
+ const columnNames = schema.columns.map((col) => col.name);
494
+ const quotedColumns = columnNames.map((name) => this.quoteIdentifier(name));
495
+ const columnCount = columnNames.length;
496
+
497
+ // Calculate batch size to stay under SQLite's parameter limit (999)
498
+ // Leave some margin for safety
499
+ const maxBatchSize = Math.floor(900 / columnCount);
500
+ const batchSize = Math.max(1, Math.min(maxBatchSize, 100));
501
+
502
+ // Process data in batches
503
+ for (let i = 0; i < data.length; i += batchSize) {
504
+ const batch = data.slice(i, i + batchSize);
505
+ const rowPlaceholders = columnNames.map(() => '?').join(', ');
506
+ const valuesPlaceholders = batch.map(() => `(${rowPlaceholders})`).join(', ');
507
+ const sql = `INSERT INTO ${this.quoteTableName(tableName)} (${quotedColumns.join(', ')}) VALUES ${valuesPlaceholders}`;
508
+
509
+ const values: (string | number | bigint | null | Uint8Array)[] = [];
510
+ for (const row of batch) {
511
+ for (const col of columnNames) {
512
+ values.push(this.normalizeValue(row[col]));
513
+ }
514
+ }
515
+
516
+ const stmt = this.db.prepare(sql);
517
+ stmt.run(...values);
518
+ }
519
+ }
520
+
521
+ /**
522
+ * Insert data into table one row at a time with detailed error reporting
523
+ * This is used for validation to catch constraint violations
524
+ */
525
+ private insertDataWithDetailedValidation(
526
+ tableName: string,
527
+ schema: TableSchema,
528
+ data: JsonObject[],
529
+ filePath: string,
530
+ ): ValidationErrorDetail[] {
531
+ const errors: ValidationErrorDetail[] = [];
378
532
  const columnNames = schema.columns.map((col) => col.name);
379
533
  const quotedColumns = columnNames.map((name) => this.quoteIdentifier(name));
380
534
  const placeholders = columnNames.map(() => '?').join(', ');
@@ -382,10 +536,90 @@ export class LinesDB<Tables extends TableDefs> {
382
536
 
383
537
  const stmt = this.db.prepare(sql);
384
538
 
385
- for (const row of data) {
386
- const values = columnNames.map((col) => this.normalizeValue(row[col]));
387
- stmt.run(...values);
539
+ for (let rowIndex = 0; rowIndex < data.length; rowIndex++) {
540
+ const row = data[rowIndex];
541
+ try {
542
+ const values = columnNames.map((col) => this.normalizeValue(row[col]));
543
+ stmt.run(...values);
544
+ } catch (error) {
545
+ // Constraint violation occurred - analyze and record details
546
+ const constraintError = this.analyzeConstraintError(
547
+ error,
548
+ filePath,
549
+ tableName,
550
+ rowIndex,
551
+ row,
552
+ schema.foreignKeys || [],
553
+ );
554
+ if (constraintError) {
555
+ errors.push(constraintError);
556
+ }
557
+ }
388
558
  }
559
+
560
+ return errors;
561
+ }
562
+
563
+ /**
564
+ * Analyze constraint error and extract detailed information
565
+ */
566
+ private analyzeConstraintError(
567
+ error: unknown,
568
+ file: string,
569
+ tableName: string,
570
+ rowIndex: number,
571
+ row: JsonObject,
572
+ foreignKeys: ForeignKeyDefinition[],
573
+ ): ValidationErrorDetail | null {
574
+ const errorMessage = error instanceof Error ? error.message : String(error);
575
+
576
+ // Foreign key constraint
577
+ if (errorMessage.includes('FOREIGN KEY constraint failed')) {
578
+ // Find which foreign key failed
579
+ for (const fk of foreignKeys) {
580
+ const fkValue = row[fk.column];
581
+ if (fkValue === null || fkValue === undefined) continue;
582
+
583
+ // Check if referenced value exists
584
+ try {
585
+ const result = this.query(
586
+ `SELECT COUNT(*) as count FROM ${this.quoteIdentifier(fk.references.table)} WHERE ${this.quoteIdentifier(fk.references.column)} = ?`,
587
+ [this.normalizeValue(fkValue)],
588
+ );
589
+ if (result.length > 0 && (result[0] as { count: number }).count === 0) {
590
+ return {
591
+ file,
592
+ tableName,
593
+ rowIndex,
594
+ issues: [],
595
+ type: 'foreignKey',
596
+ foreignKeyError: {
597
+ column: fk.column,
598
+ value: fkValue,
599
+ referencedTable: fk.references.table,
600
+ referencedColumn: fk.references.column,
601
+ },
602
+ };
603
+ }
604
+ } catch (_) {
605
+ // Referenced table doesn't exist yet
606
+ }
607
+ }
608
+ }
609
+
610
+ // Other constraint errors (primary key, unique, etc.)
611
+ return {
612
+ file,
613
+ tableName,
614
+ rowIndex,
615
+ issues: [
616
+ {
617
+ message: errorMessage,
618
+ path: [],
619
+ },
620
+ ],
621
+ type: 'schema',
622
+ };
389
623
  }
390
624
 
391
625
  /**
@@ -1112,10 +1346,20 @@ export class LinesDB<Tables extends TableDefs> {
1112
1346
  /**
1113
1347
  * Sync database changes back to JSONL files
1114
1348
  * Uses backward transformation when available
1349
+ * @param tableName Optional table name to sync. If not provided, syncs all loaded tables
1115
1350
  */
1116
- async sync(): Promise<void> {
1117
- for (const [tableName] of this.tables) {
1351
+ async sync(tableName?: string): Promise<void> {
1352
+ if (tableName) {
1353
+ // Sync only the specified table
1354
+ if (!this.schemas.has(tableName)) {
1355
+ throw new Error(`Table '${tableName}' is not loaded`);
1356
+ }
1118
1357
  await this.syncTable(tableName);
1358
+ } else {
1359
+ // Sync all tables that are loaded (present in schemas map)
1360
+ for (const [name] of this.schemas) {
1361
+ await this.syncTable(name);
1362
+ }
1119
1363
  }
1120
1364
  }
1121
1365
 
package/src/index.ts CHANGED
@@ -5,14 +5,12 @@ export { SchemaLoader } from './schema-loader.js';
5
5
  export { DirectoryScanner } from './directory-scanner.js';
6
6
  export { defineSchema, hasBackward } from './schema.js';
7
7
  export { TypeGenerator } from './type-generator.js';
8
- export { Validator } from './validator.js';
9
8
  export { ensureTableRowsValid } from './jsonl-migration.js';
10
9
  export type { TableValidationOptions } from './jsonl-migration.js';
11
10
  export { detectRuntime, RUNTIME } from './runtime.js';
12
11
  export type { RuntimeEnvironment } from './runtime.js';
13
12
  export type { SQLiteDatabase, SQLiteStatement } from './sqlite-adapter.js';
14
13
  export type { TypeGeneratorOptions } from './type-generator.js';
15
- export type { ValidatorOptions, ValidationResult, ValidationErrorDetail } from './validator.js';
16
14
  export type {
17
15
  TableSchema,
18
16
  ColumnDefinition,
@@ -25,6 +23,8 @@ export type {
25
23
  StandardSchemaResult,
26
24
  StandardSchemaIssue,
27
25
  ValidationError,
26
+ ValidationResult,
27
+ ValidationErrorDetail,
28
28
  InferInput,
29
29
  InferOutput,
30
30
  Table,
@@ -11,66 +11,34 @@ export interface TableValidationOptions {
11
11
 
12
12
  /**
13
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.
14
+ * If validation fails, throws an error with validation details.
15
15
  */
16
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
17
  const tablePath = join(options.dataDir, `${options.tableName}.jsonl`);
23
18
  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
19
 
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();
20
+ await JsonlReader.withOverrides(overrides, async () => {
21
+ const db = LinesDB.create({ dataDir: options.dataDir });
22
+ try {
23
+ // Initialize only the target table
24
+ const result = await db.initialize({ tableName: options.tableName });
25
+
26
+ // If validation failed, throw an error with details
27
+ if (!result.valid) {
28
+ const errorCount = result.errors.length;
29
+ const errorDetails = result.errors
30
+ .map((e) => {
31
+ const issueMessages = e.issues.map((issue) => issue.message).join(', ');
32
+ return ` Row ${e.rowIndex}: ${issueMessages}`;
33
+ })
34
+ .join('\n');
35
+
36
+ throw new Error(
37
+ `Validation failed for table '${options.tableName}' (${errorCount} error(s)):\n${errorDetails}`,
38
+ );
59
39
  }
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)');
40
+ } finally {
41
+ await db.close();
42
+ }
43
+ });
76
44
  }