@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.
- package/LICENSE +21 -0
- package/bin/cli.js +1373 -0
- package/dist/index.cjs +1212 -0
- package/dist/index.d.cts +486 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.ts +486 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1181 -0
- package/dist/index.js.map +1 -0
- package/package.json +64 -0
- package/src/cli.ts +333 -0
- package/src/database.test.ts +493 -0
- package/src/database.ts +1025 -0
- package/src/directory-scanner.test.ts +91 -0
- package/src/directory-scanner.ts +38 -0
- package/src/error-formatter.ts +166 -0
- package/src/index.ts +35 -0
- package/src/jsonl-migration.ts +76 -0
- package/src/jsonl-reader.test.ts +168 -0
- package/src/jsonl-reader.ts +135 -0
- package/src/jsonl-writer.test.ts +101 -0
- package/src/jsonl-writer.ts +33 -0
- package/src/runtime.ts +34 -0
- package/src/schema-loader.test.ts +136 -0
- package/src/schema-loader.ts +64 -0
- package/src/schema.ts +135 -0
- package/src/sqlite-adapter.ts +99 -0
- package/src/type-generator.ts +201 -0
- package/src/types.ts +99 -0
- package/src/validator.test.ts +337 -0
- package/src/validator.ts +207 -0
- package/tsconfig.json +20 -0
- package/tsconfig.test.json +8 -0
- package/tsdown.config.ts +26 -0
- package/vitest.config.ts +9 -0
package/src/database.ts
ADDED
|
@@ -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
|
+
}
|