@toiroakr/lines-db 0.9.0 → 0.9.1

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.
@@ -518,4 +518,164 @@ export const schema = defineSchema(rawSchema);
518
518
  await db.close();
519
519
  });
520
520
  });
521
+
522
+ describe('circular foreign key validation', () => {
523
+ const writeSchemaWithFK = async (
524
+ tableName: string,
525
+ primaryKey: string,
526
+ foreignKeys: Array<{ column: string; references: { table: string; column: string } }>,
527
+ uniqueColumns: string[] = [],
528
+ ) => {
529
+ const fkJson = JSON.stringify(foreignKeys);
530
+ const indexDefs = uniqueColumns
531
+ .map((col) => `{ name: "${tableName}_${col}_idx", columns: ["${col}"], unique: true }`)
532
+ .join(', ');
533
+ const indexesLine = uniqueColumns.length > 0 ? `indexes: [${indexDefs}],` : '';
534
+ // Use inline StandardSchema without external imports (dynamic import from /tmp can't resolve npm packages)
535
+ const source = `
536
+ const baseSchema = {
537
+ '~standard': {
538
+ version: 1,
539
+ vendor: 'test',
540
+ validate: (value) => ({ value })
541
+ }
542
+ };
543
+
544
+ export const schema = Object.assign(Object.create(baseSchema), {
545
+ '~standard': baseSchema['~standard'],
546
+ primaryKey: "${primaryKey}",
547
+ foreignKeys: ${fkJson},
548
+ ${indexesLine}
549
+ });
550
+ `;
551
+ await writeFile(join(testDir, `${tableName}.schema.ts`), source);
552
+ };
553
+
554
+ it('should validate circular foreign keys after all tables are loaded', async () => {
555
+ // authors and profiles reference each other bidirectionally
556
+ await writeFile(
557
+ join(testDir, 'authors.jsonl'),
558
+ '{"id":"1","name":"Alice","profile_code":"a-001"}\n{"id":"2","name":"Bob","profile_code":"b-001"}\n',
559
+ );
560
+ await writeSchemaWithFK(
561
+ 'authors',
562
+ 'id',
563
+ [{ column: 'profile_code', references: { table: 'profiles', column: 'code' } }],
564
+ ['profile_code'],
565
+ );
566
+
567
+ await writeFile(
568
+ join(testDir, 'profiles.jsonl'),
569
+ '{"code":"a-001","author_id":"1","bio":"Author A"}\n{"code":"b-001","author_id":"2","bio":"Author B"}\n',
570
+ );
571
+ await writeSchemaWithFK(
572
+ 'profiles',
573
+ 'code',
574
+ [{ column: 'author_id', references: { table: 'authors', column: 'id' } }],
575
+ ['code'],
576
+ );
577
+
578
+ type Tables = {
579
+ authors: { id: string; name: string; profile_code: string };
580
+ profiles: { code: string; author_id: string; bio: string };
581
+ };
582
+
583
+ const config: DatabaseConfig<Tables> = { dataDir: testDir };
584
+ const db = LinesDB.create(config);
585
+ const result = await db.initialize({ detailedValidate: true });
586
+
587
+ expect(result.valid).toBe(true);
588
+ expect(result.errors).toHaveLength(0);
589
+
590
+ await db.close();
591
+ });
592
+
593
+ it('should detect orphan in authors (no matching profile)', async () => {
594
+ await writeFile(
595
+ join(testDir, 'authors.jsonl'),
596
+ '{"id":"1","name":"Alice","profile_code":"a-001"}\n{"id":"2","name":"Orphan","profile_code":"missing"}\n',
597
+ );
598
+ await writeSchemaWithFK(
599
+ 'authors',
600
+ 'id',
601
+ [{ column: 'profile_code', references: { table: 'profiles', column: 'code' } }],
602
+ ['profile_code'],
603
+ );
604
+
605
+ await writeFile(
606
+ join(testDir, 'profiles.jsonl'),
607
+ '{"code":"a-001","author_id":"1","bio":"Author A"}\n',
608
+ );
609
+ await writeSchemaWithFK(
610
+ 'profiles',
611
+ 'code',
612
+ [{ column: 'author_id', references: { table: 'authors', column: 'id' } }],
613
+ ['code'],
614
+ );
615
+
616
+ type Tables = {
617
+ authors: { id: string; name: string; profile_code: string };
618
+ profiles: { code: string; author_id: string; bio: string };
619
+ };
620
+
621
+ const config: DatabaseConfig<Tables> = { dataDir: testDir };
622
+ const db = LinesDB.create(config);
623
+ const result = await db.initialize({ detailedValidate: true });
624
+
625
+ expect(result.valid).toBe(false);
626
+ expect(result.errors).toHaveLength(1);
627
+ expect(result.errors[0].foreignKeyError).toMatchObject({
628
+ column: 'profile_code',
629
+ value: 'missing',
630
+ referencedTable: 'profiles',
631
+ referencedColumn: 'code',
632
+ });
633
+
634
+ await db.close();
635
+ });
636
+
637
+ it('should detect orphan in profiles (no matching author)', async () => {
638
+ await writeFile(
639
+ join(testDir, 'authors.jsonl'),
640
+ '{"id":"1","name":"Alice","profile_code":"a-001"}\n',
641
+ );
642
+ await writeSchemaWithFK(
643
+ 'authors',
644
+ 'id',
645
+ [{ column: 'profile_code', references: { table: 'profiles', column: 'code' } }],
646
+ ['profile_code'],
647
+ );
648
+
649
+ await writeFile(
650
+ join(testDir, 'profiles.jsonl'),
651
+ '{"code":"a-001","author_id":"1","bio":"Author A"}\n{"code":"x-999","author_id":"999","bio":"Orphan"}\n',
652
+ );
653
+ await writeSchemaWithFK(
654
+ 'profiles',
655
+ 'code',
656
+ [{ column: 'author_id', references: { table: 'authors', column: 'id' } }],
657
+ ['code'],
658
+ );
659
+
660
+ type Tables = {
661
+ authors: { id: string; name: string; profile_code: string };
662
+ profiles: { code: string; author_id: string; bio: string };
663
+ };
664
+
665
+ const config: DatabaseConfig<Tables> = { dataDir: testDir };
666
+ const db = LinesDB.create(config);
667
+ const result = await db.initialize({ detailedValidate: true });
668
+
669
+ expect(result.valid).toBe(false);
670
+ expect(result.errors).toHaveLength(1);
671
+ expect(result.errors[0].foreignKeyError).toMatchObject({
672
+ column: 'author_id',
673
+ value: '999',
674
+ referencedTable: 'authors',
675
+ referencedColumn: 'id',
676
+ });
677
+
678
+ await db.close();
679
+ });
680
+ });
521
681
  });
package/src/database.ts CHANGED
@@ -83,6 +83,11 @@ export class LinesDB<Tables extends TableDefs> {
83
83
  const loadedTables = new Set<string>();
84
84
  const loadingTables = new Set<string>();
85
85
  const attemptedTables = new Set<string>(); // Track all attempted tables (loaded or not)
86
+ const allDeferredForeignKeys: Array<{
87
+ tableName: string;
88
+ foreignKey: ForeignKeyDefinition;
89
+ filePath: string;
90
+ }> = [];
86
91
 
87
92
  // Load tables with dependency resolution
88
93
  for (const tableNameToLoad of tablesToLoad) {
@@ -93,6 +98,7 @@ export class LinesDB<Tables extends TableDefs> {
93
98
  errors,
94
99
  warnings,
95
100
  rowCounts: tableRowCounts,
101
+ deferredForeignKeys,
96
102
  } = await this.loadTableWithDependencies(
97
103
  tableNameToLoad,
98
104
  loadedTables,
@@ -103,12 +109,25 @@ export class LinesDB<Tables extends TableDefs> {
103
109
  );
104
110
  allErrors.push(...errors);
105
111
  allWarnings.push(...warnings);
112
+ allDeferredForeignKeys.push(...deferredForeignKeys);
106
113
  for (const [k, v] of tableRowCounts) {
107
114
  allRowCounts.set(k, v);
108
115
  }
109
116
  }
110
117
  }
111
118
 
119
+ // Validate deferred foreign keys (from circular dependencies) now that all tables are loaded
120
+ if (detailedValidate && allDeferredForeignKeys.length > 0) {
121
+ for (const { tableName: tName, foreignKey: fk, filePath } of allDeferredForeignKeys) {
122
+ // Only validate if the referenced table was actually loaded
123
+ if (!loadedTables.has(fk.references.table)) {
124
+ continue;
125
+ }
126
+ const deferredErrors = this.validateDeferredForeignKey(tName, fk, filePath);
127
+ allErrors.push(...deferredErrors);
128
+ }
129
+ }
130
+
112
131
  // Build per-table results
113
132
  const tableResults: TableValidationResult[] = tablesToLoad.map((name) => {
114
133
  const tableErrors = allErrors.filter((e) => e.tableName === name);
@@ -144,14 +163,24 @@ export class LinesDB<Tables extends TableDefs> {
144
163
  errors: ValidationErrorDetail[];
145
164
  warnings: string[];
146
165
  rowCounts: Map<string, number>;
166
+ deferredForeignKeys: Array<{
167
+ tableName: string;
168
+ foreignKey: ForeignKeyDefinition;
169
+ filePath: string;
170
+ }>;
147
171
  }> {
148
172
  const errors: ValidationErrorDetail[] = [];
149
173
  const warnings: string[] = [];
150
174
  const rowCounts = new Map<string, number>();
175
+ const deferredForeignKeys: Array<{
176
+ tableName: string;
177
+ foreignKey: ForeignKeyDefinition;
178
+ filePath: string;
179
+ }> = [];
151
180
 
152
181
  // Skip if already attempted (loaded or not)
153
182
  if (attemptedTables.has(tableName)) {
154
- return { errors, warnings, rowCounts };
183
+ return { errors, warnings, rowCounts, deferredForeignKeys };
155
184
  }
156
185
 
157
186
  // Mark as attempted
@@ -218,6 +247,7 @@ export class LinesDB<Tables extends TableDefs> {
218
247
  );
219
248
  errors.push(...depResult.errors);
220
249
  warnings.push(...depResult.warnings);
250
+ deferredForeignKeys.push(...depResult.deferredForeignKeys);
221
251
  for (const [k, v] of depResult.rowCounts) {
222
252
  rowCounts.set(k, v);
223
253
  }
@@ -230,14 +260,21 @@ export class LinesDB<Tables extends TableDefs> {
230
260
  }
231
261
  }
232
262
 
233
- // Determine which FK dependencies failed validation (attempted but not loaded)
263
+ // Determine which FK dependencies failed or are circular (attempted but not loaded)
234
264
  const failedDependencies = new Set<string>();
265
+ const circularDependencies = new Set<string>();
235
266
  if (foreignKeys && foreignKeys.length > 0) {
236
267
  for (const fk of foreignKeys) {
237
268
  const referencedTable = fk.references.table;
238
269
  if (referencedTable === tableName) continue;
239
270
  if (attemptedTables.has(referencedTable) && !loadedTables.has(referencedTable)) {
240
- failedDependencies.add(referencedTable);
271
+ if (loadingTables.has(referencedTable)) {
272
+ // Circular dependency: table is currently being loaded
273
+ circularDependencies.add(referencedTable);
274
+ } else {
275
+ // Actual failure: table attempted but not loaded
276
+ failedDependencies.add(referencedTable);
277
+ }
241
278
  }
242
279
  }
243
280
  if (failedDependencies.size > 0) {
@@ -249,6 +286,9 @@ export class LinesDB<Tables extends TableDefs> {
249
286
  }
250
287
  }
251
288
 
289
+ // Combine failed and circular dependencies for table loading (both need FK skipping)
290
+ const allSkippedDependencies = new Set([...failedDependencies, ...circularDependencies]);
291
+
252
292
  // Now load this table
253
293
  const {
254
294
  loaded,
@@ -259,13 +299,26 @@ export class LinesDB<Tables extends TableDefs> {
259
299
  tableConfig,
260
300
  detailedValidate,
261
301
  transform,
262
- failedDependencies,
302
+ allSkippedDependencies,
263
303
  );
264
304
  errors.push(...loadErrors);
265
305
  rowCounts.set(tableName, rowCount);
266
306
 
267
307
  if (loaded) {
268
308
  loadedTables.add(tableName);
309
+
310
+ // Track circular dependency FKs for deferred validation
311
+ if (foreignKeys && circularDependencies.size > 0) {
312
+ for (const fk of foreignKeys) {
313
+ if (circularDependencies.has(fk.references.table)) {
314
+ deferredForeignKeys.push({
315
+ tableName,
316
+ foreignKey: fk,
317
+ filePath: tableConfig.jsonlPath,
318
+ });
319
+ }
320
+ }
321
+ }
269
322
  } else {
270
323
  // Table was not loaded (e.g., empty data)
271
324
  warnings.push(`Table '${tableName}' was not loaded (no data or skipped)`);
@@ -276,7 +329,7 @@ export class LinesDB<Tables extends TableDefs> {
276
329
  loadingTables.delete(tableName);
277
330
  }
278
331
 
279
- return { errors, warnings, rowCounts };
332
+ return { errors, warnings, rowCounts, deferredForeignKeys };
280
333
  }
281
334
 
282
335
  /**
@@ -738,6 +791,48 @@ export class LinesDB<Tables extends TableDefs> {
738
791
  };
739
792
  }
740
793
 
794
+ /**
795
+ * Validate a deferred foreign key constraint after all tables have been loaded.
796
+ * Used for circular dependency FK validation.
797
+ */
798
+ private validateDeferredForeignKey(
799
+ tableName: string,
800
+ fk: ForeignKeyDefinition,
801
+ filePath: string,
802
+ ): ValidationErrorDetail[] {
803
+ const errors: ValidationErrorDetail[] = [];
804
+ const quotedTable = this.quoteTableName(tableName);
805
+ const quotedColumn = this.quoteIdentifier(fk.column);
806
+ const quotedRefTable = this.quoteTableName(fk.references.table);
807
+ const quotedRefColumn = this.quoteIdentifier(fk.references.column);
808
+
809
+ // Find rows where the FK value does not exist in the referenced table
810
+ const sql = `SELECT rowid - 1 as idx, ${quotedColumn} as val FROM ${quotedTable} WHERE ${quotedColumn} IS NOT NULL AND ${quotedColumn} NOT IN (SELECT ${quotedRefColumn} FROM ${quotedRefTable})`;
811
+
812
+ try {
813
+ const rows = this.query<{ idx: number; val: string | number }>(sql);
814
+ for (const row of rows) {
815
+ errors.push({
816
+ file: filePath,
817
+ tableName,
818
+ rowIndex: row.idx,
819
+ issues: [],
820
+ type: 'foreignKey',
821
+ foreignKeyError: {
822
+ column: fk.column,
823
+ value: row.val,
824
+ referencedTable: fk.references.table,
825
+ referencedColumn: fk.references.column,
826
+ },
827
+ });
828
+ }
829
+ } catch (_) {
830
+ // Table might not exist - skip validation
831
+ }
832
+
833
+ return errors;
834
+ }
835
+
741
836
  /**
742
837
  * Execute a raw SQL query
743
838
  */