@toiroakr/lines-db 0.8.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.
- package/CHANGELOG.md +14 -0
- package/bin/cli.js +57 -7
- package/dist/index.cjs +163 -7
- package/dist/index.d.cts +67 -1
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +67 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +162 -8
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/database.test.ts +160 -0
- package/src/database.ts +100 -5
- package/src/index.ts +6 -0
package/src/database.test.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
*/
|
package/src/index.ts
CHANGED
|
@@ -15,6 +15,12 @@ export {
|
|
|
15
15
|
rewriteExtensionForImport,
|
|
16
16
|
} from './schema-extensions.js';
|
|
17
17
|
export type { SchemaExtension } from './schema-extensions.js';
|
|
18
|
+
export { ErrorFormatter } from './error-formatter.js';
|
|
19
|
+
export type {
|
|
20
|
+
ErrorFormatterOptions,
|
|
21
|
+
ValidationErrorInfo,
|
|
22
|
+
ForeignKeyErrorInfo,
|
|
23
|
+
} from './error-formatter.js';
|
|
18
24
|
export { detectRuntime, RUNTIME } from './runtime.js';
|
|
19
25
|
export type { RuntimeEnvironment } from './runtime.js';
|
|
20
26
|
export type { SQLiteDatabase, SQLiteStatement } from './sqlite-adapter.js';
|