@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,1025 @@
1
+ import { createDatabase, type SQLiteDatabase } from './sqlite-adapter.js';
2
+ import { JsonlReader } from './jsonl-reader.js';
3
+ import { JsonlWriter } from './jsonl-writer.js';
4
+ import { SchemaLoader } from './schema-loader.js';
5
+ import { DirectoryScanner } from './directory-scanner.js';
6
+ import { hasBackward } from './schema.js';
7
+ import type {
8
+ DatabaseConfig,
9
+ TableSchema,
10
+ JsonObject,
11
+ TableConfig,
12
+ StandardSchema,
13
+ StandardSchemaIssue,
14
+ ValidationError,
15
+ Table,
16
+ TableDefs,
17
+ WhereCondition,
18
+ } from './types.js';
19
+ import type { BiDirectionalSchema } from './schema.js';
20
+
21
+ export class LinesDB<Tables extends TableDefs> {
22
+ private db: SQLiteDatabase;
23
+ private config: DatabaseConfig<Tables>;
24
+ private schemas: Map<string, TableSchema> = new Map();
25
+ private validationSchemas: Map<string, StandardSchema | undefined> = new Map();
26
+ private tables: Map<string, TableConfig> = new Map();
27
+ private inTransaction: boolean = false;
28
+
29
+ private constructor(config: DatabaseConfig<Tables>, dbPath?: string) {
30
+ this.config = config;
31
+ this.db = createDatabase(dbPath ?? ':memory:');
32
+ }
33
+
34
+ static create<Tables extends TableDefs>(
35
+ config: DatabaseConfig<Tables>,
36
+ dbPath?: string,
37
+ ): LinesDB<Tables> {
38
+ return new LinesDB<Tables>(config, dbPath);
39
+ }
40
+
41
+ /**
42
+ * Initialize database by loading all JSONL files
43
+ */
44
+ async initialize(): Promise<void> {
45
+ // Scan directory for JSONL files
46
+ this.tables = await DirectoryScanner.scanDirectory(this.config.dataDir);
47
+
48
+ // Load all tables
49
+ for (const [tableName, tableConfig] of this.tables) {
50
+ 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
59
+ this.tables.delete(tableName);
60
+ }
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Load a single table from JSONL file
66
+ */
67
+ private async loadTable(tableName: string, config: TableConfig): Promise<void> {
68
+ // Read JSONL file
69
+ const data = await JsonlReader.read(config.jsonlPath);
70
+
71
+ if (data.length === 0) {
72
+ console.warn(`Warning: Table ${tableName} has no data`);
73
+ return;
74
+ }
75
+
76
+ // Load validation schema if provided or try to auto-load
77
+ let validationSchema = config.validationSchema;
78
+ if (!validationSchema) {
79
+ try {
80
+ validationSchema = await SchemaLoader.loadSchema(config.jsonlPath);
81
+ } catch (error) {
82
+ // 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
+ }
88
+ }
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
+
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
+ }
108
+
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
+ }
119
+ }
120
+ }
121
+ if (biSchema.foreignKeys) {
122
+ schema.foreignKeys = biSchema.foreignKeys;
123
+ }
124
+ if (biSchema.indexes) {
125
+ schema.indexes = biSchema.indexes;
126
+ }
127
+ }
128
+
129
+ this.schemas.set(tableName, schema);
130
+
131
+ // Create table
132
+ this.createTable(schema);
133
+
134
+ // Validate data before inserting
135
+ const validationErrors: Array<{
136
+ rowIndex: number;
137
+ rowData: JsonObject;
138
+ error: ValidationError;
139
+ }> = [];
140
+
141
+ for (let rowIndex = 0; rowIndex < data.length; rowIndex++) {
142
+ const row = data[rowIndex];
143
+ try {
144
+ this.validateData(tableName, row);
145
+ } catch (error) {
146
+ if (error instanceof Error && error.name === 'ValidationError') {
147
+ validationErrors.push({
148
+ rowIndex,
149
+ rowData: row,
150
+ error: error as ValidationError,
151
+ });
152
+ } else {
153
+ throw error;
154
+ }
155
+ }
156
+ }
157
+
158
+ if (validationErrors.length > 0) {
159
+ const enhancedError = new Error(
160
+ `Validation failed for ${validationErrors.length} row(s) in table ${tableName}`,
161
+ );
162
+ enhancedError.name = 'ValidationError';
163
+ (enhancedError as unknown as { validationErrors: typeof validationErrors }).validationErrors =
164
+ validationErrors;
165
+ (enhancedError as unknown as { issues: ReadonlyArray<StandardSchemaIssue> }).issues =
166
+ validationErrors[0].error.issues;
167
+ throw enhancedError;
168
+ }
169
+
170
+ this.insertData(tableName, schema, data);
171
+ }
172
+
173
+ /**
174
+ * Create table in SQLite with constraints and indexes
175
+ */
176
+ private createTable(schema: TableSchema): void {
177
+ // Enable foreign key constraints
178
+ this.db.exec('PRAGMA foreign_keys = ON');
179
+
180
+ // Quote table name to handle special characters
181
+ const quotedTableName = this.quoteTableName(schema.name);
182
+
183
+ const columnDefs = schema.columns.map((col) => {
184
+ // JSON type is stored as TEXT in SQLite
185
+ const sqlType = col.type === 'JSON' ? 'TEXT' : col.type;
186
+ const parts = [this.quoteIdentifier(col.name), sqlType];
187
+ if (col.primaryKey) parts.push('PRIMARY KEY');
188
+ if (col.notNull) parts.push('NOT NULL');
189
+ if (col.unique) parts.push('UNIQUE');
190
+ return parts.join(' ');
191
+ });
192
+
193
+ // Add foreign key constraints
194
+ const foreignKeyDefs: string[] = [];
195
+ if (schema.foreignKeys && schema.foreignKeys.length > 0) {
196
+ for (const fk of schema.foreignKeys) {
197
+ const fkParts = [
198
+ `FOREIGN KEY (${fk.columns.map((col) => this.quoteIdentifier(col)).join(', ')})`,
199
+ `REFERENCES ${this.quoteTableName(fk.references.table)}(${fk.references.columns
200
+ .map((col) => this.quoteIdentifier(col))
201
+ .join(', ')})`,
202
+ ];
203
+ if (fk.onDelete) {
204
+ fkParts.push(`ON DELETE ${fk.onDelete}`);
205
+ }
206
+ if (fk.onUpdate) {
207
+ fkParts.push(`ON UPDATE ${fk.onUpdate}`);
208
+ }
209
+ foreignKeyDefs.push(fkParts.join(' '));
210
+ }
211
+ }
212
+
213
+ const allDefs = [...columnDefs, ...foreignKeyDefs];
214
+ const sql = `CREATE TABLE IF NOT EXISTS ${quotedTableName} (${allDefs.join(', ')})`;
215
+ this.db.exec(sql);
216
+
217
+ // Create indexes
218
+ if (schema.indexes && schema.indexes.length > 0) {
219
+ for (let i = 0; i < schema.indexes.length; i++) {
220
+ const index = schema.indexes[i];
221
+ // Create safe index name by replacing special characters
222
+ const safeTableName = schema.name.replace(/[^a-zA-Z0-9]/g, '_');
223
+ const resolvedIndexName =
224
+ index.name || `idx_${safeTableName}_${index.columns.join('_')}_${i}`;
225
+ const uniqueKeyword = index.unique ? 'UNIQUE ' : '';
226
+ const indexSql = `CREATE ${uniqueKeyword}INDEX IF NOT EXISTS ${this.quoteIdentifier(resolvedIndexName)} ON ${quotedTableName} (${index.columns
227
+ .map((col) => this.quoteIdentifier(col))
228
+ .join(', ')})`;
229
+ this.db.exec(indexSql);
230
+ }
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Quote table name to handle special characters in SQL
236
+ */
237
+ private quoteTableName(tableName: string): string {
238
+ return this.quoteIdentifier(tableName);
239
+ }
240
+
241
+ /**
242
+ * Quote identifier for SQL statements, escaping embedded quotes
243
+ */
244
+ private quoteIdentifier(identifier: string): string {
245
+ return `"${identifier.replace(/"/g, '""')}"`;
246
+ }
247
+
248
+ /**
249
+ * Insert data into table
250
+ */
251
+ private insertData(tableName: string, schema: TableSchema, data: JsonObject[]): void {
252
+ const columnNames = schema.columns.map((col) => col.name);
253
+ const quotedColumns = columnNames.map((name) => this.quoteIdentifier(name));
254
+ const placeholders = columnNames.map(() => '?').join(', ');
255
+ const sql = `INSERT INTO ${this.quoteTableName(tableName)} (${quotedColumns.join(', ')}) VALUES (${placeholders})`;
256
+
257
+ const stmt = this.db.prepare(sql);
258
+
259
+ for (const row of data) {
260
+ const values = columnNames.map((col) => this.normalizeValue(row[col]));
261
+ stmt.run(...values);
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Execute a raw SQL query
267
+ */
268
+ query<T = unknown>(
269
+ sql: string,
270
+ params: (string | number | bigint | null | Uint8Array)[] = [],
271
+ ): T[] {
272
+ const stmt = this.db.prepare(sql);
273
+ return stmt.all(...params) as T[];
274
+ }
275
+
276
+ /**
277
+ * Execute a SQL query that returns a single row
278
+ */
279
+ queryOne<T = unknown>(
280
+ sql: string,
281
+ params: (string | number | bigint | null | Uint8Array)[] = [],
282
+ ): T | null {
283
+ const stmt = this.db.prepare(sql);
284
+ const result = stmt.get(...params);
285
+ return result === undefined ? null : (result as T);
286
+ }
287
+
288
+ /**
289
+ * Execute a SQL statement (INSERT, UPDATE, DELETE)
290
+ */
291
+ execute(
292
+ sql: string,
293
+ params: (string | number | bigint | null | Uint8Array)[] = [],
294
+ ): { changes: number | bigint; lastInsertRowid: number | bigint } {
295
+ const stmt = this.db.prepare(sql);
296
+ return stmt.run(...params);
297
+ }
298
+
299
+ /**
300
+ * Find rows by condition (supports OR/AND with arrays and function filters)
301
+ * If where is not provided, returns all rows
302
+ */
303
+ find<K extends keyof Tables & string>(tableName: K, where?: WhereCondition<Tables[K]>) {
304
+ // If no where condition, return all rows
305
+ if (where === undefined) {
306
+ const rows = this.query(`SELECT * FROM ${this.quoteTableName(tableName)}`);
307
+ return rows.map((row) => this.deserializeRow(tableName, row)) as Tables[K][];
308
+ }
309
+
310
+ // Handle empty array - should return no results
311
+ if (Array.isArray(where) && where.length === 0) {
312
+ return [];
313
+ }
314
+
315
+ const { sql, values, functionFilters, hasOrWithFunctionFilters } = this.buildWhereClause(where);
316
+
317
+ let rows: Tables[K][];
318
+
319
+ // If OR condition has function filters, get all rows and evaluate in JS
320
+ if (hasOrWithFunctionFilters) {
321
+ const rawRows = this.query(`SELECT * FROM ${this.quoteTableName(tableName)}`);
322
+ rows = rawRows.map((row) => this.deserializeRow(tableName, row)) as Tables[K][];
323
+ return this.applyOrConditionWithFilters(rows, where as WhereCondition<Tables[K]>);
324
+ }
325
+
326
+ // Normal case: use SQL WHERE clause
327
+ if (sql) {
328
+ const rawRows = this.query(
329
+ `SELECT * FROM ${this.quoteTableName(tableName)} WHERE ${sql}`,
330
+ values,
331
+ );
332
+ rows = rawRows.map((row) => this.deserializeRow(tableName, row)) as Tables[K][];
333
+ } else {
334
+ // If only function filters (AND case), get all rows
335
+ const rawRows = this.query(`SELECT * FROM ${this.quoteTableName(tableName)}`);
336
+ rows = rawRows.map((row) => this.deserializeRow(tableName, row)) as Tables[K][];
337
+ }
338
+
339
+ // Apply function filters for AND conditions
340
+ return this.applyFunctionFilters(rows, functionFilters);
341
+ }
342
+
343
+ /**
344
+ * Find a single row by condition (supports OR/AND with arrays and function filters)
345
+ */
346
+ findOne<K extends keyof Tables & string>(tableName: K, where: WhereCondition<Tables[K]>) {
347
+ const { sql, values, functionFilters } = this.buildWhereClause(where);
348
+
349
+ let rows: Tables[K][];
350
+ if (sql) {
351
+ const rawRows = this.query(
352
+ `SELECT * FROM ${this.quoteTableName(tableName)} WHERE ${sql}`,
353
+ values,
354
+ );
355
+ rows = rawRows.map((row) => this.deserializeRow(tableName, row)) as Tables[K][];
356
+ } else {
357
+ // If only function filters, get all rows
358
+ const rawRows = this.query(`SELECT * FROM ${this.quoteTableName(tableName)}`);
359
+ rows = rawRows.map((row) => this.deserializeRow(tableName, row)) as Tables[K][];
360
+ }
361
+
362
+ // Apply function filters and return first match
363
+ const filtered = this.applyFunctionFilters(rows, functionFilters);
364
+ return filtered.length > 0 ? filtered[0] : null;
365
+ }
366
+
367
+ /**
368
+ * Deserialize JSON columns in a row
369
+ */
370
+ private deserializeRow<T>(tableName: string, row: T): T {
371
+ const schema = this.schemas.get(tableName);
372
+ if (!schema) return row;
373
+
374
+ const deserializedRow = { ...row } as Record<string, unknown>;
375
+
376
+ for (const column of schema.columns) {
377
+ const colName = column.name;
378
+ if (!(colName in deserializedRow)) continue;
379
+
380
+ const value = deserializedRow[colName];
381
+
382
+ if (column.type === 'JSON' && typeof value === 'string') {
383
+ try {
384
+ deserializedRow[colName] = JSON.parse(value);
385
+ } catch (error) {
386
+ // If parsing fails, keep the original value
387
+ console.warn(`Failed to parse JSON column ${colName}:`, error);
388
+ }
389
+ continue;
390
+ }
391
+
392
+ if (column.valueType === 'boolean') {
393
+ if (typeof value === 'number') {
394
+ deserializedRow[colName] = value === 0 ? false : true;
395
+ } else if (typeof value === 'bigint') {
396
+ deserializedRow[colName] = value === 0n ? false : true;
397
+ }
398
+ }
399
+ }
400
+
401
+ return deserializedRow as T;
402
+ }
403
+
404
+ /**
405
+ * Validate data using StandardSchema
406
+ * Note: Only synchronous validation is supported
407
+ */
408
+ private validateData(tableName: string, data: unknown): void {
409
+ const schema = this.validationSchemas.get(tableName);
410
+ console.log(`[LinesDB] validateData called for table '${tableName}', schema exists:`, !!schema);
411
+ if (!schema) {
412
+ console.log(
413
+ `[LinesDB] No validation schema found for table '${tableName}', skipping validation`,
414
+ );
415
+ return;
416
+ }
417
+ console.log(`[LinesDB] Validating data:`, JSON.stringify(data));
418
+
419
+ const result = schema['~standard'].validate(data);
420
+
421
+ // Only synchronous validation is supported
422
+ if (result instanceof Promise) {
423
+ throw new Error(
424
+ 'Asynchronous validation is not supported. Please use synchronous validation schemas.',
425
+ );
426
+ }
427
+
428
+ if (result.issues && result.issues.length > 0) {
429
+ // Format detailed error message with all validation issues
430
+ const issueMessages = result.issues
431
+ .map((issue) => {
432
+ // Handle path: can be array of PathSegment or undefined
433
+ let pathStr = 'root';
434
+ if (issue.path && issue.path.length > 0) {
435
+ pathStr = issue.path
436
+ .map((segment) => {
437
+ // PathSegment can be { key: PropertyKey } or just PropertyKey
438
+ if (typeof segment === 'object' && segment !== null && 'key' in segment) {
439
+ return String(segment.key);
440
+ }
441
+ return String(segment);
442
+ })
443
+ .join('.');
444
+ }
445
+ return ` - ${pathStr}: ${issue.message}`;
446
+ })
447
+ .join('\n');
448
+
449
+ const errorMessage = `Validation failed for table '${tableName}':\n${issueMessages}`;
450
+ const error = new Error(errorMessage) as ValidationError;
451
+ error.name = 'ValidationError';
452
+ error.issues = result.issues;
453
+ throw error;
454
+ }
455
+ }
456
+
457
+ /**
458
+ * Insert a row into a table with validation
459
+ */
460
+ insert<K extends keyof Tables & string>(
461
+ tableName: K,
462
+ data: Tables[K],
463
+ ): { changes: number | bigint; lastInsertRowid: number | bigint } {
464
+ // Validate if schema exists
465
+ this.validateData(tableName, data);
466
+
467
+ const schema = this.schemas.get(tableName);
468
+ if (!schema) {
469
+ throw new Error(`Table ${tableName} does not exist`);
470
+ }
471
+
472
+ const columnNames = Object.keys(data);
473
+ const quotedColumns = columnNames.map((col) => this.quoteIdentifier(col));
474
+ const placeholders = columnNames.map(() => '?').join(', ');
475
+ const sql = `INSERT INTO ${this.quoteTableName(tableName)} (${quotedColumns.join(', ')}) VALUES (${placeholders})`;
476
+
477
+ const values = Object.values(data).map((v) => this.normalizeValue(v));
478
+ const result = this.execute(sql, values);
479
+
480
+ // Auto-sync if not in transaction
481
+ if (!this.inTransaction) {
482
+ this.syncTable(tableName).catch((err) => {
483
+ console.error(`Failed to sync table ${tableName}:`, err);
484
+ });
485
+ }
486
+
487
+ return result;
488
+ }
489
+
490
+ /**
491
+ * Batch insert rows with validation per record.
492
+ */
493
+ batchInsert<K extends keyof Tables & string>(
494
+ tableName: K,
495
+ records: Tables[K][],
496
+ ): { changes: number | bigint; lastInsertRowid: number | bigint } {
497
+ const schema = this.schemas.get(tableName);
498
+ if (!schema) {
499
+ throw new Error(`Table ${tableName} does not exist`);
500
+ }
501
+
502
+ if (records.length === 0) {
503
+ return { changes: 0, lastInsertRowid: 0 };
504
+ }
505
+
506
+ let totalChanges = 0n;
507
+ let lastRowid = 0n;
508
+
509
+ for (const record of records) {
510
+ this.validateData(tableName, record);
511
+
512
+ const columnNames = Object.keys(record);
513
+ const quotedColumns = columnNames.map((col) => this.quoteIdentifier(col));
514
+ const placeholders = columnNames.map(() => '?').join(', ');
515
+ const sql = `INSERT INTO ${this.quoteTableName(tableName)} (${quotedColumns.join(', ')}) VALUES (${placeholders})`;
516
+
517
+ const values = columnNames.map((col) => this.normalizeValue(record[col as keyof Tables[K]]));
518
+
519
+ const result = this.execute(sql, values);
520
+ totalChanges += BigInt(result.changes);
521
+ lastRowid = BigInt(result.lastInsertRowid);
522
+ }
523
+
524
+ if (!this.inTransaction) {
525
+ this.syncTable(tableName).catch((err) => {
526
+ console.error(`Failed to sync table ${tableName}:`, err);
527
+ });
528
+ }
529
+
530
+ return {
531
+ changes: totalChanges,
532
+ lastInsertRowid: lastRowid,
533
+ };
534
+ }
535
+
536
+ /**
537
+ * Update rows in a table with validation (supports OR/AND with arrays)
538
+ * Note: Function filters are not supported for update operations
539
+ * Note: By default, validation is enabled. For partial updates, existing data is fetched
540
+ * and merged before validation. Set options.validate = false to disable validation.
541
+ */
542
+ update<K extends keyof Tables & string>(
543
+ tableName: K,
544
+ data: Partial<Tables[K]>,
545
+ where: WhereCondition<Tables[K]>,
546
+ options?: { validate?: boolean },
547
+ ): { changes: number | bigint; lastInsertRowid: number | bigint } {
548
+ const schema = this.schemas.get(tableName);
549
+ if (!schema) {
550
+ throw new Error(`Table ${tableName} does not exist`);
551
+ }
552
+
553
+ // Validate by default (can be disabled with validate: false)
554
+ const shouldValidate = options?.validate !== false;
555
+ const hasValidationSchema = this.validationSchemas.has(tableName);
556
+
557
+ if (shouldValidate && hasValidationSchema) {
558
+ // Get existing rows to merge with partial data
559
+ const existingRows = this.find(tableName, where);
560
+
561
+ // Validate each merged row
562
+ for (const existingRow of existingRows) {
563
+ const mergedData = { ...existingRow, ...data };
564
+ this.validateData(tableName, mergedData);
565
+ }
566
+ }
567
+
568
+ const { sql: whereSql, values: whereValues, functionFilters } = this.buildWhereClause(where);
569
+
570
+ if (functionFilters.length > 0) {
571
+ throw new Error('Function filters are not supported in update operations');
572
+ }
573
+
574
+ const setClauses = Object.keys(data)
575
+ .map((key) => `${this.quoteIdentifier(key)} = ?`)
576
+ .join(', ');
577
+ const sql = `UPDATE ${this.quoteTableName(tableName)} SET ${setClauses} WHERE ${whereSql}`;
578
+
579
+ const values = [...Object.values(data).map((v) => this.normalizeValue(v)), ...whereValues];
580
+
581
+ const result = this.execute(sql, values);
582
+
583
+ // Auto-sync if not in transaction
584
+ if (!this.inTransaction) {
585
+ this.syncTable(tableName).catch((err) => {
586
+ console.error(`Failed to sync table ${tableName}:`, err);
587
+ });
588
+ }
589
+
590
+ return result;
591
+ }
592
+
593
+ /**
594
+ * Batch update rows with record-specific values and validation.
595
+ * Each record must include the primary key to identify the target row.
596
+ * Validation runs once per merged record unless explicitly disabled.
597
+ */
598
+ batchUpdate<K extends keyof Tables & string>(
599
+ tableName: K,
600
+ records: Array<Partial<Tables[K]> & Record<string, unknown>>,
601
+ options?: { validate?: boolean },
602
+ ): { changes: number | bigint; lastInsertRowid: number | bigint } {
603
+ const schema = this.schemas.get(tableName);
604
+ if (!schema) {
605
+ throw new Error(`Table ${tableName} does not exist`);
606
+ }
607
+
608
+ if (records.length === 0) {
609
+ return { changes: 0, lastInsertRowid: 0 };
610
+ }
611
+
612
+ // Get primary key column
613
+ const pkColumn = schema.columns.find((col) => col.primaryKey);
614
+ if (!pkColumn) {
615
+ throw new Error(`Table ${tableName} does not have a primary key`);
616
+ }
617
+
618
+ const pkName = pkColumn.name;
619
+
620
+ // Extract primary key values from records
621
+ const pkValues: unknown[] = [];
622
+ for (const record of records) {
623
+ const pkValue = record[pkName];
624
+ if (pkValue === undefined) {
625
+ throw new Error(
626
+ `Record is missing primary key '${String(pkName)}': ${JSON.stringify(record)}`,
627
+ );
628
+ }
629
+ pkValues.push(pkValue);
630
+ }
631
+
632
+ // Validate by default (can be disabled with validate: false)
633
+ const shouldValidate = options?.validate !== false;
634
+ const hasValidationSchema = this.validationSchemas.has(tableName);
635
+
636
+ if (shouldValidate && hasValidationSchema) {
637
+ // Build OR condition to fetch all existing rows at once
638
+ const orCondition = pkValues.map((pkValue) => ({
639
+ [pkName]: pkValue,
640
+ })) as WhereCondition<Tables[K]>;
641
+
642
+ // Fetch all existing rows in one query
643
+ const existingRows = this.find(tableName, orCondition);
644
+
645
+ // Create a map for fast lookup: pkValue -> existingRow
646
+ const existingRowsMap = new Map<unknown, Tables[K]>();
647
+ for (const row of existingRows) {
648
+ const pkValue = (row as Record<string, unknown>)[pkName];
649
+ existingRowsMap.set(pkValue, row);
650
+ }
651
+
652
+ // Validate each merged record and collect all errors
653
+ const validationErrors: Array<{
654
+ rowIndex: number;
655
+ rowData: unknown;
656
+ pkValue: unknown;
657
+ error: ValidationError;
658
+ }> = [];
659
+
660
+ for (let i = 0; i < records.length; i++) {
661
+ const record = records[i];
662
+ const pkValue = record[pkName];
663
+ const existingRow = existingRowsMap.get(pkValue);
664
+
665
+ if (!existingRow) {
666
+ throw new Error(
667
+ `No existing row found with ${String(pkName)}=${JSON.stringify(pkValue)}`,
668
+ );
669
+ }
670
+
671
+ const mergedData = { ...existingRow, ...record };
672
+
673
+ try {
674
+ this.validateData(tableName, mergedData);
675
+ } catch (error) {
676
+ // Collect validation errors instead of throwing immediately
677
+ if (error instanceof Error && error.name === 'ValidationError') {
678
+ validationErrors.push({
679
+ rowIndex: i,
680
+ rowData: mergedData,
681
+ pkValue,
682
+ error: error as ValidationError,
683
+ });
684
+ } else {
685
+ throw error;
686
+ }
687
+ }
688
+ }
689
+
690
+ // If there are validation errors, throw with all error information
691
+ if (validationErrors.length > 0) {
692
+ const enhancedError = new Error(
693
+ `Validation failed for ${validationErrors.length} row(s)`,
694
+ ) as ValidationError & { validationErrors: typeof validationErrors };
695
+ enhancedError.name = 'ValidationError';
696
+ enhancedError.validationErrors = validationErrors;
697
+ // For backward compatibility, include issues from first error
698
+ enhancedError.issues = validationErrors[0].error.issues;
699
+ throw enhancedError;
700
+ }
701
+ }
702
+
703
+ // All validations passed - perform updates
704
+ let totalChanges = 0n;
705
+ let lastRowid = 0n;
706
+
707
+ for (const record of records) {
708
+ const pkValue = record[pkName];
709
+ const where = { [pkName]: pkValue } as WhereCondition<Tables[K]>;
710
+
711
+ // Call update without validation (already validated above)
712
+ const result = this.update(tableName, record as Partial<Tables[K]>, where, {
713
+ validate: false,
714
+ });
715
+
716
+ totalChanges += BigInt(result.changes);
717
+ lastRowid = BigInt(result.lastInsertRowid);
718
+ }
719
+
720
+ return {
721
+ changes: totalChanges,
722
+ lastInsertRowid: lastRowid,
723
+ };
724
+ }
725
+
726
+ /**
727
+ * Delete rows from a table (supports OR/AND with arrays)
728
+ * Note: Function filters are not supported for delete operations
729
+ */
730
+ delete<K extends keyof Tables & string>(
731
+ tableName: K,
732
+ where: WhereCondition<Tables[K]>,
733
+ ): { changes: number | bigint; lastInsertRowid: number | bigint } {
734
+ const schema = this.schemas.get(tableName);
735
+ if (!schema) {
736
+ throw new Error(`Table ${tableName} does not exist`);
737
+ }
738
+
739
+ const { sql: whereSql, values, functionFilters } = this.buildWhereClause(where);
740
+
741
+ if (functionFilters.length > 0) {
742
+ throw new Error('Function filters are not supported in delete operations');
743
+ }
744
+
745
+ const sql = `DELETE FROM ${this.quoteTableName(tableName)} WHERE ${whereSql}`;
746
+ const result = this.execute(sql, values);
747
+
748
+ // Auto-sync if not in transaction
749
+ if (!this.inTransaction) {
750
+ this.syncTable(tableName).catch((err) => {
751
+ console.error(`Failed to sync table ${tableName}:`, err);
752
+ });
753
+ }
754
+
755
+ return result;
756
+ }
757
+
758
+ /**
759
+ * Batch delete rows by primary key.
760
+ */
761
+ batchDelete<K extends keyof Tables & string>(
762
+ tableName: K,
763
+ records: Array<Partial<Tables[K]> & Record<string, unknown>>,
764
+ ): { changes: number | bigint; lastInsertRowid: number | bigint } {
765
+ const schema = this.schemas.get(tableName);
766
+ if (!schema) {
767
+ throw new Error(`Table ${tableName} does not exist`);
768
+ }
769
+
770
+ if (records.length === 0) {
771
+ return { changes: 0, lastInsertRowid: 0 };
772
+ }
773
+
774
+ const pkColumn = schema.columns.find((col) => col.primaryKey);
775
+ if (!pkColumn) {
776
+ throw new Error(`Table ${tableName} does not have a primary key`);
777
+ }
778
+ const pkName = pkColumn.name;
779
+
780
+ const pkValues = records.map((record, index) => {
781
+ const pkValue = record[pkName as keyof Tables[K]];
782
+ if (pkValue === undefined) {
783
+ throw new Error(`Record at index ${index} is missing primary key '${String(pkName)}'`);
784
+ }
785
+ return pkValue;
786
+ });
787
+
788
+ const placeholders = pkValues.map(() => '?').join(', ');
789
+ const sql = `DELETE FROM ${this.quoteTableName(tableName)} WHERE ${this.quoteIdentifier(pkName)} IN (${placeholders})`;
790
+ const values = pkValues.map((value) => this.normalizeValue(value));
791
+
792
+ const result = this.execute(sql, values);
793
+
794
+ if (!this.inTransaction) {
795
+ this.syncTable(tableName).catch((err) => {
796
+ console.error(`Failed to sync table ${tableName}:`, err);
797
+ });
798
+ }
799
+
800
+ return {
801
+ changes: BigInt(result.changes),
802
+ lastInsertRowid: BigInt(result.lastInsertRowid),
803
+ };
804
+ }
805
+
806
+ /**
807
+ * Normalize value for SQLite
808
+ */
809
+ private normalizeValue(value: unknown): string | number | bigint | null | Uint8Array {
810
+ if (value === null || value === undefined) return null;
811
+ if (typeof value === 'boolean') return value ? 1 : 0;
812
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'bigint')
813
+ return value;
814
+ if (value instanceof Uint8Array) return value;
815
+ // For objects, convert to JSON string
816
+ return JSON.stringify(value);
817
+ }
818
+
819
+ /**
820
+ * Build WHERE clause from condition (supports OR/AND with arrays and functions)
821
+ */
822
+ private buildWhereClause<T extends Record<string, unknown>>(
823
+ condition: WhereCondition<T>,
824
+ ): {
825
+ sql: string;
826
+ values: Array<string | number | bigint | null | Uint8Array>;
827
+ functionFilters: Array<{
828
+ key: string;
829
+ fn: (value: unknown) => boolean;
830
+ }>;
831
+ hasOrWithFunctionFilters: boolean;
832
+ } {
833
+ const values: Array<string | number | bigint | null | Uint8Array> = [];
834
+ const functionFilters: Array<{ key: string; fn: (value: unknown) => boolean }> = [];
835
+ let hasOrWithFunctionFilters = false;
836
+
837
+ const buildCondition = (cond: WhereCondition<T>, isInOr = false): string => {
838
+ // Handle array (OR conditions)
839
+ if (Array.isArray(cond)) {
840
+ const clauses = cond
841
+ .map((item) => {
842
+ const clause = Array.isArray(item)
843
+ ? buildCondition(item, true)
844
+ : buildCondition(item, true);
845
+ return clause ? `(${clause})` : '';
846
+ })
847
+ .filter((clause) => clause !== ''); // Filter out empty clauses
848
+
849
+ return clauses.join(' OR ');
850
+ }
851
+
852
+ // Handle object (AND conditions)
853
+ const conditions: string[] = [];
854
+ let hasFunctionFilter = false;
855
+ for (const [key, value] of Object.entries(cond)) {
856
+ if (typeof value === 'function') {
857
+ // Function filter - will be applied later
858
+ functionFilters.push({ key, fn: value as (value: unknown) => boolean });
859
+ hasFunctionFilter = true;
860
+ } else {
861
+ // Regular value
862
+ conditions.push(`${this.quoteIdentifier(key)} = ?`);
863
+ values.push(this.normalizeValue(value));
864
+ }
865
+ }
866
+
867
+ if (isInOr && hasFunctionFilter) {
868
+ hasOrWithFunctionFilters = true;
869
+ }
870
+
871
+ return conditions.join(' AND ');
872
+ };
873
+
874
+ const sql = buildCondition(condition);
875
+ return { sql, values, functionFilters, hasOrWithFunctionFilters };
876
+ }
877
+
878
+ /**
879
+ * Apply OR condition with function filters by evaluating each row against the condition
880
+ */
881
+ private applyOrConditionWithFilters<T extends Record<string, unknown>>(
882
+ rows: T[],
883
+ condition: WhereCondition<T>,
884
+ ): T[] {
885
+ return rows.filter((row) => this.matchesOrCondition(row, condition));
886
+ }
887
+
888
+ /**
889
+ * Check if a row matches an OR/AND condition (recursively)
890
+ */
891
+ private matchesOrCondition<T extends Record<string, unknown>>(
892
+ row: T,
893
+ condition: WhereCondition<T>,
894
+ ): boolean {
895
+ // Handle array (OR conditions)
896
+ if (Array.isArray(condition)) {
897
+ return condition.some((item) => this.matchesOrCondition(row, item));
898
+ }
899
+
900
+ // Handle object (AND conditions)
901
+ return Object.entries(condition).every(([key, value]) => {
902
+ const rowValue = row[key as keyof T];
903
+ if (typeof value === 'function') {
904
+ return (value as (value: unknown) => boolean)(rowValue);
905
+ }
906
+ return rowValue === value;
907
+ });
908
+ }
909
+
910
+ /**
911
+ * Apply function filters to rows
912
+ */
913
+ private applyFunctionFilters<T extends Record<string, unknown>>(
914
+ rows: T[],
915
+ functionFilters: Array<{ key: string; fn: (value: unknown) => boolean }>,
916
+ ): T[] {
917
+ if (functionFilters.length === 0) return rows;
918
+
919
+ return rows.filter((row) => {
920
+ return functionFilters.every(({ key, fn }) => {
921
+ const value = row[key as keyof T];
922
+ return fn(value);
923
+ });
924
+ });
925
+ }
926
+
927
+ /**
928
+ * Get table schema
929
+ */
930
+ getSchema(tableName: string): TableSchema | undefined {
931
+ return this.schemas.get(tableName);
932
+ }
933
+
934
+ /**
935
+ * Get all table names
936
+ */
937
+ getTableNames(): string[] {
938
+ return Array.from(this.schemas.keys());
939
+ }
940
+
941
+ /**
942
+ * Sync a specific table back to its JSONL file
943
+ * Uses backward transformation when available
944
+ */
945
+ private async syncTable(tableName: string): Promise<void> {
946
+ const tableConfig = this.tables.get(tableName);
947
+ if (!tableConfig) {
948
+ throw new Error(`Table ${tableName} not found`);
949
+ }
950
+
951
+ // Get all rows from the table
952
+ const rows = this.query<JsonObject>(`SELECT * FROM ${this.quoteTableName(tableName)}`);
953
+
954
+ // Deserialize JSON columns
955
+ const deserializedRows = rows.map((row) => this.deserializeRow(tableName, row));
956
+
957
+ // Apply backward transformation if available
958
+ const validationSchema = this.validationSchemas.get(tableName);
959
+ let finalRows = deserializedRows;
960
+
961
+ if (validationSchema && hasBackward(validationSchema)) {
962
+ const biSchema = validationSchema as BiDirectionalSchema<Table, Table>;
963
+ finalRows = deserializedRows.map((row) => biSchema.backward!(row) as JsonObject);
964
+ }
965
+
966
+ // Write back to JSONL file
967
+ await JsonlWriter.write(tableConfig.jsonlPath, finalRows);
968
+ }
969
+
970
+ /**
971
+ * Sync database changes back to JSONL files
972
+ * Uses backward transformation when available
973
+ */
974
+ async sync(): Promise<void> {
975
+ for (const [tableName] of this.tables) {
976
+ await this.syncTable(tableName);
977
+ }
978
+ }
979
+
980
+ /**
981
+ * Execute a function within a transaction
982
+ * Automatically commits on success or rolls back on error
983
+ */
984
+ async transaction<T>(fn: (tx: LinesDB<Tables>) => Promise<T> | T): Promise<T> {
985
+ if (this.inTransaction) {
986
+ throw new Error('Nested transactions are not supported');
987
+ }
988
+
989
+ this.db.exec('BEGIN TRANSACTION');
990
+ this.inTransaction = true;
991
+
992
+ try {
993
+ const result = await fn(this);
994
+ this.db.exec('COMMIT');
995
+ this.inTransaction = false;
996
+
997
+ // Sync all tables after successful commit
998
+ await this.sync();
999
+
1000
+ return result;
1001
+ } catch (error) {
1002
+ this.db.exec('ROLLBACK');
1003
+ this.inTransaction = false;
1004
+ throw error;
1005
+ }
1006
+ }
1007
+
1008
+ /**
1009
+ * Close the database connection
1010
+ */
1011
+ async close(): Promise<void> {
1012
+ try {
1013
+ this.db.close();
1014
+ } catch (_error) {
1015
+ // Ignore errors if database is already closed
1016
+ }
1017
+ }
1018
+
1019
+ /**
1020
+ * Get the underlying SQLite database instance
1021
+ */
1022
+ getDb(): SQLiteDatabase {
1023
+ return this.db;
1024
+ }
1025
+ }