@toiroakr/lines-db 0.2.1 → 0.3.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
@@ -40,108 +40,172 @@ export class LinesDB<Tables extends TableDefs> {
40
40
 
41
41
  /**
42
42
  * Initialize database by loading all JSONL files
43
+ * Uses dependency resolution to ensure foreign key references are loaded in correct order
43
44
  */
44
45
  async initialize(): Promise<void> {
45
46
  // Scan directory for JSONL files
46
47
  this.tables = await DirectoryScanner.scanDirectory(this.config.dataDir);
47
48
 
48
- // Load all tables
49
- for (const [tableName, tableConfig] of this.tables) {
49
+ // Track loaded tables and tables currently being loaded (for circular dependency detection)
50
+ const loadedTables = new Set<string>();
51
+ 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
+ }
69
+ }
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Load a table and its dependencies recursively
75
+ */
76
+ private async loadTableWithDependencies(
77
+ tableName: string,
78
+ loadedTables: Set<string>,
79
+ loadingTables: Set<string>,
80
+ ): Promise<void> {
81
+ // Skip if already loaded
82
+ if (loadedTables.has(tableName)) {
83
+ return;
84
+ }
85
+
86
+ // Check for circular dependencies
87
+ if (loadingTables.has(tableName)) {
88
+ throw new Error(`Circular dependency detected for table '${tableName}'`);
89
+ }
90
+
91
+ // Get table config
92
+ const tableConfig = this.tables.get(tableName);
93
+ if (!tableConfig) {
94
+ throw new Error(`Table configuration not found for '${tableName}'`);
95
+ }
96
+
97
+ // Mark as currently loading
98
+ loadingTables.add(tableName);
99
+
100
+ try {
101
+ // Load schema module to check for foreign key dependencies
102
+ // We need to load the entire module to access foreignKeys export
103
+ let foreignKeys: BiDirectionalSchema['foreignKeys'];
104
+
50
105
  try {
51
- await this.loadTable(tableName, tableConfig);
52
- } catch (error) {
53
- // Log error but continue loading other tables
54
- console.warn(
55
- `Warning: Failed to load table '${tableName}':`,
56
- error instanceof Error ? error.message : String(error),
57
- );
58
- // Remove the failed table from the tables map
106
+ const { pathToFileURL } = await import('node:url');
107
+ const schemaPath = tableConfig.jsonlPath.replace('.jsonl', '.schema.ts');
108
+ const schemaUrl = pathToFileURL(schemaPath).href;
109
+ const schemaModule = await import(`${schemaUrl}?t=${Date.now()}`);
110
+ foreignKeys = schemaModule.foreignKeys;
111
+ } catch {
112
+ // Schema file not found - will continue without validation
113
+ }
114
+
115
+ // If there are foreign key dependencies, load them first
116
+ if (foreignKeys && foreignKeys.length > 0) {
117
+ for (const fk of foreignKeys) {
118
+ const referencedTable = fk.references.table;
119
+ if (!loadedTables.has(referencedTable)) {
120
+ // Check if referenced table exists in our tables map
121
+ if (this.tables.has(referencedTable)) {
122
+ await this.loadTableWithDependencies(referencedTable, loadedTables, loadingTables);
123
+ } else {
124
+ throw new Error(
125
+ `Foreign key reference to non-existent table '${referencedTable}' in table '${tableName}'`,
126
+ );
127
+ }
128
+ }
129
+ }
130
+ }
131
+
132
+ // Now load this table
133
+ const wasLoaded = await this.loadTable(tableName, tableConfig);
134
+ if (wasLoaded) {
135
+ loadedTables.add(tableName);
136
+ } else {
137
+ // Table was not loaded (e.g., empty data)
59
138
  this.tables.delete(tableName);
60
139
  }
140
+ } finally {
141
+ // Remove from loading set
142
+ loadingTables.delete(tableName);
61
143
  }
62
144
  }
63
145
 
64
146
  /**
65
147
  * Load a single table from JSONL file
148
+ * @returns true if table was loaded, false if skipped
66
149
  */
67
- private async loadTable(tableName: string, config: TableConfig): Promise<void> {
150
+ private async loadTable(tableName: string, config: TableConfig): Promise<boolean> {
68
151
  // Read JSONL file
69
152
  const data = await JsonlReader.read(config.jsonlPath);
70
153
 
71
- if (data.length === 0) {
72
- console.warn(`Warning: Table ${tableName} has no data`);
73
- return;
74
- }
75
-
76
154
  // Load validation schema if provided or try to auto-load
77
155
  let validationSchema = config.validationSchema;
156
+ const schemaMetadata: {
157
+ primaryKey?: readonly string[];
158
+ foreignKeys?: BiDirectionalSchema['foreignKeys'];
159
+ indexes?: BiDirectionalSchema['indexes'];
160
+ } = {};
161
+
78
162
  if (!validationSchema) {
79
163
  try {
80
164
  validationSchema = await SchemaLoader.loadSchema(config.jsonlPath);
81
- } catch (error) {
165
+ } catch (_error) {
82
166
  // Schema file not found or failed to load - this is OK, table can still be used without validation
83
- console.log(
84
- `[LinesDB] No validation schema for table '${tableName}':`,
85
- error instanceof Error ? error.message : String(error),
86
- );
87
167
  }
88
168
  }
89
- console.log(
90
- `[LinesDB] Loaded validation schema for table '${tableName}':`,
91
- validationSchema ? 'FOUND' : 'NOT FOUND',
92
- );
93
- if (validationSchema) {
94
- console.log(`[LinesDB] Schema type:`, typeof validationSchema);
95
- console.log(`[LinesDB] Schema has '~standard':`, '~standard' in validationSchema);
96
- }
97
- this.validationSchemas.set(tableName, validationSchema);
98
169
 
99
- // Determine schema
100
- let schema: TableSchema;
101
- if (config.schema) {
102
- schema = config.schema;
103
- } else if (config.autoInferSchema !== false) {
104
- schema = JsonlReader.inferSchema(tableName, data);
105
- } else {
106
- throw new Error(`No schema provided for table ${tableName} and autoInferSchema is disabled`);
107
- }
170
+ // Load schema metadata (foreignKeys, primaryKey, indexes) from schema module
171
+ // SchemaLoader.loadSchema() only returns the validation schema object, not metadata
172
+ if (!config.validationSchema) {
173
+ // Only load if not already provided via config
174
+ try {
175
+ const { pathToFileURL } = await import('node:url');
176
+ const schemaPath = config.jsonlPath.replace('.jsonl', '.schema.ts');
177
+ const schemaUrl = pathToFileURL(schemaPath).href;
178
+ const schemaModule = await import(`${schemaUrl}?t=${Date.now()}`);
108
179
 
109
- // Enhance schema with constraints from validation schema (if available)
110
- if (validationSchema) {
111
- const biSchema = validationSchema as BiDirectionalSchema;
112
- if (biSchema.primaryKey && !schema.columns.some((col) => col.primaryKey)) {
113
- // Add primary key constraint to columns
114
- for (const pkColumn of biSchema.primaryKey) {
115
- const col = schema.columns.find((c) => c.name === pkColumn);
116
- if (col) {
117
- col.primaryKey = true;
118
- }
180
+ if (schemaModule.primaryKey) {
181
+ schemaMetadata.primaryKey = schemaModule.primaryKey;
119
182
  }
120
- }
121
- if (biSchema.foreignKeys) {
122
- schema.foreignKeys = biSchema.foreignKeys;
123
- }
124
- if (biSchema.indexes) {
125
- schema.indexes = biSchema.indexes;
183
+ if (schemaModule.foreignKeys) {
184
+ schemaMetadata.foreignKeys = schemaModule.foreignKeys;
185
+ }
186
+ if (schemaModule.indexes) {
187
+ schemaMetadata.indexes = schemaModule.indexes;
188
+ }
189
+ } catch (_error) {
190
+ // Schema file not found - this is OK
126
191
  }
127
192
  }
128
193
 
129
- this.schemas.set(tableName, schema);
130
-
131
- // Create table
132
- this.createTable(schema);
194
+ this.validationSchemas.set(tableName, validationSchema);
133
195
 
134
- // Validate data before inserting
196
+ // Validate data first and collect validated (transformed) data
135
197
  const validationErrors: Array<{
136
198
  rowIndex: number;
137
199
  rowData: JsonObject;
138
200
  error: ValidationError;
139
201
  }> = [];
202
+ const validatedData: JsonObject[] = [];
140
203
 
141
204
  for (let rowIndex = 0; rowIndex < data.length; rowIndex++) {
142
205
  const row = data[rowIndex];
143
206
  try {
144
- this.validateData(tableName, row);
207
+ const validatedRow = this.validateAndTransform(tableName, row);
208
+ validatedData.push(validatedRow);
145
209
  } catch (error) {
146
210
  if (error instanceof Error && error.name === 'ValidationError') {
147
211
  validationErrors.push({
@@ -167,15 +231,60 @@ export class LinesDB<Tables extends TableDefs> {
167
231
  throw enhancedError;
168
232
  }
169
233
 
170
- this.insertData(tableName, schema, data);
234
+ // Determine schema - infer from validated data if auto-inference is enabled
235
+ let schema: TableSchema;
236
+ if (config.schema) {
237
+ schema = config.schema;
238
+ } else if (config.autoInferSchema !== false) {
239
+ if (validatedData.length === 0) {
240
+ return false;
241
+ }
242
+ // Infer schema from validated data (which may have additional fields added by validation)
243
+ schema = JsonlReader.inferSchema(tableName, validatedData);
244
+ } else {
245
+ throw new Error(`No schema provided for table ${tableName} and autoInferSchema is disabled`);
246
+ }
247
+
248
+ // Enhance schema with constraints from validation schema and schema metadata
249
+ // Priority: config.validationSchema (as BiDirectionalSchema) > schemaMetadata
250
+ const biSchema = validationSchema as BiDirectionalSchema;
251
+ const primaryKey = biSchema?.primaryKey || schemaMetadata.primaryKey;
252
+ const foreignKeys = biSchema?.foreignKeys || schemaMetadata.foreignKeys;
253
+ const indexes = biSchema?.indexes || schemaMetadata.indexes;
254
+
255
+ if (primaryKey && !schema.columns.some((col) => col.primaryKey)) {
256
+ // Add primary key constraint to columns
257
+ for (const pkColumn of primaryKey) {
258
+ const col = schema.columns.find((c) => c.name === pkColumn);
259
+ if (col) {
260
+ col.primaryKey = true;
261
+ }
262
+ }
263
+ }
264
+ if (foreignKeys) {
265
+ schema.foreignKeys = foreignKeys;
266
+ }
267
+ if (indexes) {
268
+ schema.indexes = indexes;
269
+ }
270
+
271
+ this.schemas.set(tableName, schema);
272
+
273
+ // Create table
274
+ this.createTable(schema);
275
+
276
+ // Insert validated data
277
+ this.insertData(tableName, schema, validatedData);
278
+
279
+ return true;
171
280
  }
172
281
 
173
282
  /**
174
283
  * Create table in SQLite with constraints and indexes
175
284
  */
176
285
  private createTable(schema: TableSchema): void {
177
- // Enable foreign key constraints
178
- this.db.exec('PRAGMA foreign_keys = ON');
286
+ // Note: Foreign key constraints are enabled at database connection level (see sqlite-adapter.ts)
287
+ // No need to enable them here for each table
179
288
 
180
289
  // Quote table name to handle special characters
181
290
  const quotedTableName = this.quoteTableName(schema.name);
@@ -402,19 +511,14 @@ export class LinesDB<Tables extends TableDefs> {
402
511
  }
403
512
 
404
513
  /**
405
- * Validate data using StandardSchema
514
+ * Validate data using StandardSchema and return the transformed value
406
515
  * Note: Only synchronous validation is supported
407
516
  */
408
- private validateData(tableName: string, data: unknown): void {
517
+ private validateAndTransform(tableName: string, data: unknown): JsonObject {
409
518
  const schema = this.validationSchemas.get(tableName);
410
- console.log(`[LinesDB] validateData called for table '${tableName}', schema exists:`, !!schema);
411
519
  if (!schema) {
412
- console.log(
413
- `[LinesDB] No validation schema found for table '${tableName}', skipping validation`,
414
- );
415
- return;
520
+ return data as JsonObject;
416
521
  }
417
- console.log(`[LinesDB] Validating data:`, JSON.stringify(data));
418
522
 
419
523
  const result = schema['~standard'].validate(data);
420
524
 
@@ -452,6 +556,27 @@ export class LinesDB<Tables extends TableDefs> {
452
556
  error.issues = result.issues;
453
557
  throw error;
454
558
  }
559
+
560
+ // Return the transformed value from validation
561
+ // When there are no issues, result.value should be present
562
+ const transformedValue = ('value' in result ? result.value : data) as JsonObject;
563
+
564
+ // Convert undefined values to null for JSON compatibility
565
+ const normalizedValue: JsonObject = {};
566
+ for (const [key, value] of Object.entries(transformedValue)) {
567
+ normalizedValue[key] = value === undefined ? null : value;
568
+ }
569
+
570
+ return normalizedValue;
571
+ }
572
+
573
+ /**
574
+ * Validate data using StandardSchema (without returning transformed value)
575
+ * Note: Only synchronous validation is supported
576
+ */
577
+ private validateData(tableName: string, data: unknown): void {
578
+ // Use validateAndTransform but discard the result
579
+ this.validateAndTransform(tableName, data);
455
580
  }
456
581
 
457
582
  /**
@@ -999,7 +1124,9 @@ export class LinesDB<Tables extends TableDefs> {
999
1124
 
1000
1125
  return result;
1001
1126
  } catch (error) {
1002
- this.db.exec('ROLLBACK');
1127
+ if (this.inTransaction) {
1128
+ this.db.exec('ROLLBACK');
1129
+ }
1003
1130
  this.inTransaction = false;
1004
1131
  throw error;
1005
1132
  }
@@ -123,7 +123,7 @@ export class JsonlReader {
123
123
  }
124
124
 
125
125
  private static inferType(value: unknown): string {
126
- if (value === null) return 'NULL';
126
+ if (value === null || value === undefined) return 'NULL';
127
127
  if (typeof value === 'number') {
128
128
  return Number.isInteger(value) ? 'INTEGER' : 'REAL';
129
129
  }
@@ -39,6 +39,10 @@ function createNodeDatabase(path: string): SQLiteDatabase {
39
39
  const { DatabaseSync } = require('node:sqlite');
40
40
  const db = new DatabaseSync(path);
41
41
 
42
+ // CRITICAL: Enable foreign key constraints
43
+ // SQLite disables foreign keys by default, which is a major data integrity issue
44
+ db.exec('PRAGMA foreign_keys = ON');
45
+
42
46
  return {
43
47
  prepare(sql: string): SQLiteStatement {
44
48
  const stmt = db.prepare(sql);