@dockstat/sqlite-wrapper 1.2.8 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -1,858 +1,1120 @@
1
- import { Database, type SQLQueryBindings } from "bun:sqlite";
2
- import { createLogger } from "@dockstat/logger";
3
- import { QueryBuilder } from "./query-builder/index";
4
- import type {
5
- ColumnConstraints,
6
- ColumnDefinition,
7
- DefaultExpression,
8
- ForeignKeyAction,
9
- JsonColumnConfig,
10
- SQLiteType,
11
- TableConstraints,
12
- TableOptions,
13
- TableSchema,
14
- } from "./types";
15
-
16
- const logger = createLogger("sqlite-wrapper");
17
-
18
- /**
19
- * Re-export all types and utilities
20
- */
21
- export { QueryBuilder };
22
- export type {
23
- InsertResult,
24
- UpdateResult,
25
- DeleteResult,
26
- InsertOptions,
27
- ColumnNames,
28
- WhereCondition,
29
- RegexCondition,
30
- JsonColumnConfig,
31
- TableSchema,
32
- ColumnDefinition,
33
- SQLiteType,
34
- ColumnConstraints,
35
- TableOptions,
36
- DefaultExpression,
37
- ForeignKeyAction,
38
- TableConstraints,
39
- } from "./types";
40
-
41
- // Re-export helper utilities
42
- export {
43
- column,
44
- sql,
45
- SQLiteTypes,
46
- SQLiteFunctions,
47
- SQLiteKeywords,
48
- defaultExpr,
49
- } from "./types";
50
-
51
- /**
52
- * TypedSQLite comprehensive wrapper around bun:sqlite `Database`.
53
- *
54
- * This class provides full type safety for SQLite operations with support for:
55
- * - All SQLite data types and variations
56
- * - Built-in SQL functions
57
- * - Complex constraints and relationships
58
- * - Generated columns
59
- * - Table-level constraints
60
- * - JSON column support
61
- * - And much more...
62
- */
63
- class DB {
64
- protected db: Database;
65
-
66
- /**
67
- * Open or create a SQLite database at `path`.
68
- *
69
- * @param path - Path to the SQLite file (e.g. "app.db"). Use ":memory:" for in-memory DB.
70
- * @param options - Optional database configuration
71
- */
72
- constructor(
73
- path: string,
74
- options?: {
75
- pragmas?: Array<[string, SQLQueryBindings]>;
76
- loadExtensions?: string[];
77
- }
78
- ) {
79
- logger.info(`Opening database: ${path}`);
80
- this.db = new Database(path);
81
-
82
- // Apply PRAGMA settings if provided
83
- if (options?.pragmas) {
84
- for (const [name, value] of options.pragmas) {
85
- this.pragma(name, value);
86
- }
87
- }
88
-
89
- // Load extensions if provided
90
- if (options?.loadExtensions) {
91
- for (const extensionPath of options.loadExtensions) {
92
- this.loadExtension(extensionPath);
93
- }
94
- }
95
- }
96
-
97
- /**
98
- * Get a typed QueryBuilder for a given table name.
99
- * (Documentation remains the same as before...)
100
- */
101
- table<T extends Record<string, unknown>>(
102
- tableName: string,
103
- jsonConfig?: JsonColumnConfig<T>
104
- ): QueryBuilder<T> {
105
- logger.debug(
106
- `Creating QueryBuilder for table: ${tableName} - JSONConfig: ${JSON.stringify(
107
- jsonConfig
108
- )}`
109
- );
110
- return new QueryBuilder<T>(this.db, tableName, jsonConfig);
111
- }
112
-
113
- /**
114
- * Close the underlying SQLite database handle.
115
- */
116
- close(): void {
117
- logger.info("Closing database connection");
118
- this.db.close();
119
- }
120
-
121
- /**
122
- * Create a table with comprehensive type safety and feature support.
123
- *
124
- * Now supports all SQLite features:
125
- *
126
- * **Basic Usage:**
127
- * ```ts
128
- * import { column, sql } from "./db";
129
- *
130
- * db.createTable("users", {
131
- * id: column.id(), // Auto-incrementing primary key
132
- * email: column.varchar(255, { unique: true, notNull: true }),
133
- * name: column.text({ notNull: true }),
134
- * age: column.integer({ check: 'age >= 0 AND age <= 150' }),
135
- * balance: column.numeric({ precision: 10, scale: 2, default: 0 }),
136
- * is_active: column.boolean({ default: sql.true() }),
137
- * metadata: column.json({ validateJson: true }),
138
- * created_at: column.createdAt(),
139
- * updated_at: column.updatedAt(),
140
- * });
141
- * ```
142
- *
143
- * **Advanced Features:**
144
- * ```ts
145
- * db.createTable("orders", {
146
- * id: column.id(),
147
- * order_number: column.varchar(50, {
148
- * unique: true,
149
- * default: sql.raw("'ORD-' || strftime('%Y%m%d', 'now') || '-' || substr(hex(randomblob(4)), 1, 8)")
150
- * }),
151
- * customer_id: column.foreignKey('users', 'id', {
152
- * onDelete: 'CASCADE',
153
- * onUpdate: 'RESTRICT'
154
- * }),
155
- * status: column.enum(['pending', 'paid', 'shipped', 'delivered'], {
156
- * default: 'pending'
157
- * }),
158
- * total: column.numeric({ precision: 10, scale: 2, notNull: true }),
159
- * // Generated column
160
- * display_total: {
161
- * type: 'TEXT',
162
- * generated: {
163
- * expression: "printf('$%.2f', total)",
164
- * stored: false // VIRTUAL column
165
- * }
166
- * },
167
- * }, {
168
- * constraints: {
169
- * check: ['total >= 0'],
170
- * unique: [['customer_id', 'order_number']]
171
- * }
172
- * });
173
- * ```
174
- *
175
- * **Date/Time Columns:**
176
- * ```ts
177
- * db.createTable("events", {
178
- * id: column.id(),
179
- * name: column.text({ notNull: true }),
180
- * event_date: column.date({ notNull: true }),
181
- * start_time: column.time(),
182
- * created_at: column.timestamp({ default: sql.unixTimestamp() }),
183
- * expires_at: column.datetime({
184
- * default: sql.raw("datetime('now', '+1 year')")
185
- * }),
186
- * });
187
- * ```
188
- *
189
- * **JSON and Advanced Types:**
190
- * ```ts
191
- * db.createTable("products", {
192
- * id: column.uuid({ generateDefault: true }), // UUID primary key
193
- * name: column.text({ notNull: true }),
194
- * price: column.real({ check: 'price > 0' }),
195
- * specifications: column.json({ validateJson: true }),
196
- * tags: column.text(), // JSON array
197
- * image_data: column.blob(),
198
- * search_vector: {
199
- * type: 'TEXT',
200
- * generated: {
201
- * expression: "lower(name || ' ' || coalesce(json_extract(specifications, '$.description'), ''))",
202
- * stored: true // STORED for indexing
203
- * }
204
- * }
205
- * });
206
- * ```
207
- *
208
- * @param tableName - Table name to create.
209
- * @param columns - Column definitions (string, legacy object, or type-safe schema).
210
- * @param options - Table options including constraints and metadata.
211
- *
212
- * @throws {Error} If column definitions are invalid or constraints conflict.
213
- */
214
- createTable<_T extends Record<string, unknown>>(
215
- tableName: string,
216
- columns:
217
- | string
218
- | Record<string, string>
219
- | Partial<Record<Extract<keyof _T, string>, ColumnDefinition>>
220
- | TableSchema,
221
- options?: TableOptions<_T>
222
- ): QueryBuilder<_T> {
223
- const temp = options?.temporary ? "TEMPORARY " : "";
224
- const ifNot = options?.ifNotExists ? "IF NOT EXISTS " : "";
225
- const withoutRowId = options?.withoutRowId ? " WITHOUT ROWID" : "";
226
-
227
- const quoteIdent = (s: string) => `"${s.replace(/"/g, '""')}"`;
228
-
229
- let columnDefs: string;
230
- let tableConstraints: string[] = [];
231
-
232
- if (typeof columns === "string") {
233
- // Original string-based approach
234
- columnDefs = columns.trim();
235
- if (!columnDefs) {
236
- throw new Error("Empty column definition string");
237
- }
238
- } else if (this.isTableSchema(columns)) {
239
- // New comprehensive type-safe approach
240
- const parts: string[] = [];
241
- for (const [colName, colDef] of Object.entries(columns)) {
242
- if (!colName) continue;
243
-
244
- const sqlDef = this.buildColumnSQL(colName, colDef);
245
- parts.push(`${quoteIdent(colName)} ${sqlDef}`);
246
- }
247
-
248
- if (parts.length === 0) {
249
- throw new Error("No columns provided");
250
- }
251
- columnDefs = parts.join(", ");
252
-
253
- // Add table-level constraints
254
- if (options?.constraints) {
255
- tableConstraints = this.buildTableConstraints(options.constraints);
256
- }
257
- } else {
258
- // Original object-based approach
259
- const parts: string[] = [];
260
- for (const [col, def] of Object.entries(columns)) {
261
- if (!col) continue;
262
-
263
- const defTrim = (def ?? "").trim();
264
- if (!defTrim) {
265
- throw new Error(`Missing SQL type/constraints for column "${col}"`);
266
- }
267
- parts.push(`${quoteIdent(col)} ${defTrim}`);
268
- }
269
-
270
- if (parts.length === 0) {
271
- throw new Error("No columns provided");
272
- }
273
- columnDefs = parts.join(", ");
274
- }
275
-
276
- // Combine column definitions and table constraints
277
- const allDefinitions = [columnDefs, ...tableConstraints].join(", ");
278
-
279
- const sql = `CREATE ${temp}TABLE ${ifNot}${quoteIdent(
280
- tableName
281
- )} (${allDefinitions})${withoutRowId};`;
282
-
283
- this.db.run(sql);
284
-
285
- // Store table comment as metadata if provided
286
- if (options?.comment) {
287
- this.setTableComment(tableName, options.comment);
288
- }
289
-
290
- return this.table<_T>(tableName, options?.jsonConfig);
291
- }
292
-
293
- /**
294
- * Create an index on a table
295
- */
296
- createIndex(
297
- indexName: string,
298
- tableName: string,
299
- columns: string | string[],
300
- options?: {
301
- unique?: boolean;
302
- ifNotExists?: boolean;
303
- where?: string;
304
- partial?: string;
305
- }
306
- ): void {
307
- const unique = options?.unique ? "UNIQUE " : "";
308
- const ifNot = options?.ifNotExists ? "IF NOT EXISTS " : "";
309
- const quoteIdent = (s: string) => `"${s.replace(/"/g, '""')}"`;
310
-
311
- const columnList = Array.isArray(columns)
312
- ? columns.map(quoteIdent).join(", ")
313
- : quoteIdent(columns);
314
-
315
- let sql = `CREATE ${unique}INDEX ${ifNot}${quoteIdent(
316
- indexName
317
- )} ON ${quoteIdent(tableName)} (${columnList})`;
318
-
319
- if (options?.where) {
320
- sql += ` WHERE ${options.where}`;
321
- }
322
-
323
- this.db.run(`${sql};`);
324
- }
325
-
326
- /**
327
- * Drop a table
328
- */
329
- dropTable(tableName: string, options?: { ifExists?: boolean }): void {
330
- const ifExists = options?.ifExists ? "IF EXISTS " : "";
331
- const quoteIdent = (s: string) => `"${s.replace(/"/g, '""')}"`;
332
-
333
- const sql = `DROP TABLE ${ifExists}${quoteIdent(tableName)};`;
334
- this.db.run(sql);
335
- }
336
-
337
- /**
338
- * Drop an index
339
- */
340
- dropIndex(indexName: string, options?: { ifExists?: boolean }): void {
341
- const ifExists = options?.ifExists ? "IF EXISTS " : "";
342
- const quoteIdent = (s: string) => `"${s.replace(/"/g, '""')}"`;
343
-
344
- const sql = `DROP INDEX ${ifExists}${quoteIdent(indexName)};`;
345
- this.db.run(sql);
346
- }
347
-
348
- /**
349
- * Type guard to check if columns definition is a TableSchema
350
- */
351
- private isTableSchema(columns: unknown): columns is TableSchema {
352
- if (typeof columns !== "object" || columns === null) {
353
- return false;
354
- }
355
-
356
- // Check if any value has a 'type' property with a valid SQLite type
357
- for (const [_key, value] of Object.entries(columns)) {
358
- if (typeof value === "object" && value !== null && "type" in value) {
359
- const type = (value as { type: string }).type;
360
- const validTypes = [
361
- "INTEGER",
362
- "TEXT",
363
- "REAL",
364
- "BLOB",
365
- "NUMERIC",
366
- "INT",
367
- "TINYINT",
368
- "SMALLINT",
369
- "MEDIUMINT",
370
- "BIGINT",
371
- "VARCHAR",
372
- "CHAR",
373
- "CHARACTER",
374
- "NCHAR",
375
- "NVARCHAR",
376
- "CLOB",
377
- "DOUBLE",
378
- "FLOAT",
379
- "DECIMAL",
380
- "DATE",
381
- "DATETIME",
382
- "TIMESTAMP",
383
- "TIME",
384
- "BOOLEAN",
385
- "JSON",
386
- ];
387
- if (validTypes.includes(type)) {
388
- return true;
389
- }
390
- }
391
- }
392
-
393
- return false;
394
- }
395
-
396
- /**
397
- * Build SQL column definition from ColumnDefinition object
398
- */
399
- private buildColumnSQL(columnName: string, colDef: ColumnDefinition): string {
400
- const parts: string[] = [];
401
-
402
- // Add type with optional parameters
403
- let typeStr = colDef.type;
404
- if (colDef.length) {
405
- typeStr += `(${colDef.length})`;
406
- } else if (colDef.precision !== undefined) {
407
- if (colDef.scale !== undefined) {
408
- typeStr += `(${colDef.precision}, ${colDef.scale})`;
409
- } else {
410
- typeStr += `(${colDef.precision})`;
411
- }
412
- }
413
- parts.push(typeStr);
414
-
415
- // Add PRIMARY KEY (must come before AUTOINCREMENT)
416
- if (colDef.primaryKey) {
417
- parts.push("PRIMARY KEY");
418
- }
419
-
420
- // Add AUTOINCREMENT (only valid with INTEGER PRIMARY KEY)
421
- if (colDef.autoincrement) {
422
- if (!colDef.type.includes("INT") || !colDef.primaryKey) {
423
- throw new Error(
424
- `AUTOINCREMENT can only be used with INTEGER PRIMARY KEY columns (column: ${columnName})`
425
- );
426
- }
427
- parts.push("AUTOINCREMENT");
428
- }
429
-
430
- // Add NOT NULL (but skip if PRIMARY KEY is already specified, as it's implicit)
431
- if (colDef.notNull && !colDef.primaryKey) {
432
- parts.push("NOT NULL");
433
- }
434
-
435
- // Add UNIQUE
436
- if (colDef.unique) {
437
- parts.push("UNIQUE");
438
- }
439
-
440
- // Add DEFAULT
441
- if (colDef.default !== undefined) {
442
- if (colDef.default === null) {
443
- parts.push("DEFAULT NULL");
444
- } else if (
445
- typeof colDef.default === "object" &&
446
- colDef.default._type === "expression"
447
- ) {
448
- // Handle DefaultExpression
449
- parts.push(`DEFAULT (${colDef.default.expression})`);
450
- } else if (typeof colDef.default === "string") {
451
- // Handle string defaults - check if it's a function call or literal
452
- if (this.isSQLFunction(colDef.default)) {
453
- parts.push(`DEFAULT (${colDef.default})`);
454
- } else {
455
- // Literal string value
456
- parts.push(`DEFAULT '${colDef.default.replace(/'/g, "''")}'`);
457
- }
458
- } else if (typeof colDef.default === "boolean") {
459
- parts.push(`DEFAULT ${colDef.default ? 1 : 0}`);
460
- } else {
461
- parts.push(`DEFAULT ${colDef.default}`);
462
- }
463
- }
464
-
465
- // Add COLLATE
466
- if (colDef.collate) {
467
- parts.push(`COLLATE ${colDef.collate}`);
468
- }
469
-
470
- // Add CHECK constraint (replace placeholder with actual column name)
471
- if (colDef.check) {
472
- const checkConstraint = colDef.check.replace(
473
- /\{\{COLUMN\}\}/g,
474
- `"${columnName.replace(/"/g, '""')}"`
475
- );
476
- parts.push(`CHECK (${checkConstraint})`);
477
- }
478
-
479
- // Add REFERENCES (foreign key)
480
- if (colDef.references) {
481
- const ref = colDef.references;
482
- let refClause = `REFERENCES "${ref.table.replace(
483
- /"/g,
484
- '""'
485
- )}"("${ref.column.replace(/"/g, '""')}")`;
486
-
487
- if (ref.onDelete) {
488
- refClause += ` ON DELETE ${ref.onDelete}`;
489
- }
490
-
491
- if (ref.onUpdate) {
492
- refClause += ` ON UPDATE ${ref.onUpdate}`;
493
- }
494
-
495
- parts.push(refClause);
496
- }
497
-
498
- // Add GENERATED column
499
- if (colDef.generated) {
500
- const storageType = colDef.generated.stored ? "STORED" : "VIRTUAL";
501
- parts.push(
502
- `GENERATED ALWAYS AS (${colDef.generated.expression}) ${storageType}`
503
- );
504
- }
505
-
506
- return parts.join(" ");
507
- }
508
-
509
- /**
510
- * Build table-level constraints
511
- */
512
- private buildTableConstraints(constraints: TableConstraints): string[] {
513
- const parts: string[] = [];
514
-
515
- // PRIMARY KEY constraint
516
- if (constraints.primaryKey && constraints.primaryKey.length > 0) {
517
- const columns = constraints.primaryKey
518
- .map((col) => `"${col.replace(/"/g, '""')}"`)
519
- .join(", ");
520
- parts.push(`PRIMARY KEY (${columns})`);
521
- }
522
-
523
- // UNIQUE constraints
524
- if (constraints.unique) {
525
- if (Array.isArray(constraints.unique[0])) {
526
- // Multiple composite unique constraints
527
- for (const uniqueGroup of constraints.unique as string[][]) {
528
- const columns = uniqueGroup
529
- .map((col) => `"${col.replace(/"/g, '""')}"`)
530
- .join(", ");
531
- parts.push(`UNIQUE (${columns})`);
532
- }
533
- } else {
534
- // Single unique constraint
535
- const columns = (constraints.unique as string[])
536
- .map((col) => `"${col.replace(/"/g, '""')}"`)
537
- .join(", ");
538
- parts.push(`UNIQUE (${columns})`);
539
- }
540
- }
541
-
542
- // CHECK constraints
543
- if (constraints.check) {
544
- for (const checkExpr of constraints.check) {
545
- parts.push(`CHECK (${checkExpr})`);
546
- }
547
- }
548
-
549
- // FOREIGN KEY constraints
550
- if (constraints.foreignKeys) {
551
- for (const fk of constraints.foreignKeys) {
552
- const columns = fk.columns
553
- .map((col) => `"${col.replace(/"/g, '""')}"`)
554
- .join(", ");
555
- const refColumns = fk.references.columns
556
- .map((col) => `"${col.replace(/"/g, '""')}"`)
557
- .join(", ");
558
-
559
- let fkClause = `FOREIGN KEY (${columns}) REFERENCES "${fk.references.table.replace(
560
- /"/g,
561
- '""'
562
- )}" (${refColumns})`;
563
-
564
- if (fk.references.onDelete) {
565
- fkClause += ` ON DELETE ${fk.references.onDelete}`;
566
- }
567
-
568
- if (fk.references.onUpdate) {
569
- fkClause += ` ON UPDATE ${fk.references.onUpdate}`;
570
- }
571
-
572
- parts.push(fkClause);
573
- }
574
- }
575
-
576
- return parts;
577
- }
578
-
579
- /**
580
- * Check if a string looks like a SQL function call
581
- */
582
- private isSQLFunction(str: string): boolean {
583
- // Simple heuristic: contains parentheses and common SQL function patterns
584
- const functionPatterns = [
585
- /^\w+\s*\(/, // Function name followed by (
586
- /^(datetime|date|time|strftime|current_timestamp|current_date|current_time)/i,
587
- /^(random|abs|length|upper|lower|trim)/i,
588
- /^(coalesce|ifnull|nullif|iif)/i,
589
- /^(json|json_extract|json_valid)/i,
590
- ];
591
-
592
- return functionPatterns.some((pattern) => pattern.test(str.trim()));
593
- }
594
-
595
- /**
596
- * Store table comment as metadata (using a system table if needed)
597
- */
598
- private setTableComment(tableName: string, comment: string): void {
599
- // Create metadata table if it doesn't exist
600
- try {
601
- this.db.run(`
602
- CREATE TABLE IF NOT EXISTS __table_metadata__ (
603
- table_name TEXT PRIMARY KEY,
604
- comment TEXT,
605
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
606
- )
607
- `);
608
-
609
- // Insert or replace comment
610
- const stmt = this.db.prepare(`
611
- INSERT OR REPLACE INTO __table_metadata__ (table_name, comment, created_at)
612
- VALUES (?, ?, CURRENT_TIMESTAMP)
613
- `);
614
- stmt.run(tableName, comment);
615
- } catch (error) {
616
- // Silently ignore if we can't create metadata table
617
- console.warn(`Could not store table comment for ${tableName}:`, error);
618
- }
619
- }
620
-
621
- /**
622
- * Get table comment from metadata
623
- */
624
- getTableComment(tableName: string): string | null {
625
- try {
626
- const stmt = this.db.prepare(`
627
- SELECT comment FROM __table_metadata__ WHERE table_name = ?
628
- `);
629
- const result = stmt.get(tableName) as { comment: string } | undefined;
630
- return result?.comment || null;
631
- } catch (_error) {
632
- return null;
633
- }
634
- }
635
-
636
- /**
637
- * runute a raw SQL statement
638
- */
639
- run(sql: string): void {
640
- logger.debug(`runuting SQL: ${sql}`);
641
- this.db.run(sql);
642
- }
643
-
644
- /**
645
- * Prepare a SQL statement for repeated runution
646
- */
647
- prepare(sql: string) {
648
- return this.db.prepare(sql);
649
- }
650
-
651
- /**
652
- * runute a transaction
653
- */
654
- transaction<T>(fn: () => T): T {
655
- return this.db.transaction(fn)();
656
- }
657
-
658
- /**
659
- * Begin a transaction manually
660
- */
661
- begin(mode?: "DEFERRED" | "IMMEDIATE" | "EXCLUSIVE"): void {
662
- const modeStr = mode ? ` ${mode}` : "";
663
- this.db.run(`BEGIN${modeStr}`);
664
- }
665
-
666
- /**
667
- * Commit a transaction
668
- */
669
- commit(): void {
670
- logger.debug("Committing transaction");
671
- this.run("COMMIT");
672
- }
673
-
674
- /**
675
- * Rollback a transaction
676
- */
677
- rollback(): void {
678
- logger.warn("Rolling back transaction");
679
- this.run("ROLLBACK");
680
- }
681
-
682
- /**
683
- * Create a savepoint
684
- */
685
- savepoint(name: string): void {
686
- const quotedName = `"${name.replace(/"/g, '""')}"`;
687
- this.db.run(`SAVEPOINT ${quotedName}`);
688
- }
689
-
690
- /**
691
- * Release a savepoint
692
- */
693
- releaseSavepoint(name: string): void {
694
- const quotedName = `"${name.replace(/"/g, '""')}"`;
695
- this.db.run(`RELEASE SAVEPOINT ${quotedName}`);
696
- }
697
-
698
- /**
699
- * Rollback to a savepoint
700
- */
701
- rollbackToSavepoint(name: string): void {
702
- const quotedName = `"${name.replace(/"/g, '""')}"`;
703
- this.db.run(`ROLLBACK TO SAVEPOINT ${quotedName}`);
704
- }
705
-
706
- /**
707
- * Vacuum the database (reclaim space and optimize)
708
- */
709
- vacuum(): void {
710
- this.db.run("VACUUM");
711
- }
712
-
713
- /**
714
- * Analyze the database (update statistics for query optimizer)
715
- */
716
- analyze(tableName?: string): void {
717
- if (tableName) {
718
- const quotedName = `"${tableName.replace(/"/g, '""')}"`;
719
- this.db.run(`ANALYZE ${quotedName}`);
720
- } else {
721
- this.db.run("ANALYZE");
722
- }
723
- }
724
-
725
- /**
726
- * Check database integrity
727
- */
728
- integrityCheck(): Array<{ integrity_check: string }> {
729
- const stmt = this.db.prepare("PRAGMA integrity_check");
730
- return stmt.all() as Array<{ integrity_check: string }>;
731
- }
732
-
733
- /**
734
- * Get database schema information
735
- */
736
- getSchema(): Array<{ name: string; type: string; sql: string }> {
737
- const stmt = this.db.prepare(`
738
- SELECT name, type, sql
739
- FROM sqlite_master
740
- WHERE type IN ('table', 'index', 'view', 'trigger')
741
- ORDER BY type, name
742
- `);
743
- return stmt.all() as Array<{ name: string; type: string; sql: string }>;
744
- }
745
-
746
- /**
747
- * Get table info (columns, types, constraints)
748
- */
749
- getTableInfo(tableName: string): Array<{
750
- cid: number;
751
- name: string;
752
- type: string;
753
- notnull: number;
754
- dflt_value: SQLQueryBindings;
755
- pk: number;
756
- }> {
757
- const stmt = this.db.prepare(
758
- `PRAGMA table_info("${tableName.replace(/"/g, '""')}")`
759
- );
760
- return stmt.all() as Array<{
761
- cid: number;
762
- name: string;
763
- type: string;
764
- notnull: number;
765
- dflt_value: SQLQueryBindings;
766
- pk: number;
767
- }>;
768
- }
769
-
770
- /**
771
- * Get foreign key information for a table
772
- */
773
- getForeignKeys(tableName: string): Array<{
774
- id: number;
775
- seq: number;
776
- table: string;
777
- from: string;
778
- to: string;
779
- on_update: string;
780
- on_delete: string;
781
- match: string;
782
- }> {
783
- const stmt = this.db.prepare(
784
- `PRAGMA foreign_key_list("${tableName.replace(/"/g, '""')}")`
785
- );
786
- return stmt.all() as Array<{
787
- id: number;
788
- seq: number;
789
- table: string;
790
- from: string;
791
- to: string;
792
- on_update: string;
793
- on_delete: string;
794
- match: string;
795
- }>;
796
- }
797
-
798
- /**
799
- * Get index information for a table
800
- */
801
- getIndexes(tableName: string): Array<{
802
- name: string;
803
- unique: number;
804
- origin: string;
805
- partial: number;
806
- }> {
807
- const stmt = this.db.prepare(
808
- `PRAGMA index_list("${tableName.replace(/"/g, '""')}")`
809
- );
810
- return stmt.all() as Array<{
811
- name: string;
812
- unique: number;
813
- origin: string;
814
- partial: number;
815
- }>;
816
- }
817
-
818
- /**
819
- * Set or get a PRAGMA value.
820
- *
821
- * @param name - PRAGMA name (e.g., "foreign_keys", "journal_mode")
822
- * @param value - Value to set (omit to get current value)
823
- * @returns Current value when getting, undefined when setting
824
- */
825
- pragma(name: string, value?: SQLQueryBindings): SQLQueryBindings | undefined {
826
- if (value !== undefined) {
827
- this.db.run(`PRAGMA ${name} = ${value}`);
828
- return undefined;
829
- }
830
- const result = this.db.prepare(`PRAGMA ${name}`).get() as Record<
831
- string,
832
- SQLQueryBindings
833
- >;
834
- return Object.values(result)[0];
835
- }
836
-
837
- /**
838
- * Load a SQLite extension.
839
- *
840
- * @param path - Absolute path to the compiled SQLite extension
841
- */
842
- loadExtension(path: string): void {
843
- this.db.loadExtension(path);
844
- }
845
-
846
- /**
847
- * Get direct access to the underlying SQLite database instance.
848
- * Use this for advanced operations not covered by the wrapper.
849
- *
850
- * @returns The underlying Database instance
851
- */
852
- getDb(): Database {
853
- return this.db;
854
- }
855
- }
856
-
857
- export { DB };
858
- export default DB;
1
+ import { Database, type SQLQueryBindings } from "bun:sqlite"
2
+ import { QueryBuilder } from "./query-builder/index"
3
+ import type { ColumnDefinition, Parser, TableConstraints, TableOptions, TableSchema } from "./types"
4
+ import { addLoggerParents as addParents, createLogger, logger as sqliteLogger } from "./utils"
5
+
6
+ // Re-export logger utilities for external use
7
+ export const logger = sqliteLogger
8
+ export const addLoggerParents = addParents
9
+
10
+ // Internal loggers for different components
11
+ const dbLog = createLogger("db")
12
+ const backupLog = createLogger("backup")
13
+ const tableLog = createLogger("table")
14
+
15
+ /**
16
+ * Re-export all types and utilities
17
+ */
18
+ export { QueryBuilder }
19
+ export type {
20
+ ArrayKey,
21
+ ColumnConstraints,
22
+ ColumnDefinition,
23
+ ColumnNames,
24
+ DefaultExpression,
25
+ DeleteResult,
26
+ ForeignKeyAction,
27
+ InsertOptions,
28
+ InsertResult,
29
+ RegexCondition,
30
+ SQLiteType,
31
+ TableConstraints,
32
+ TableOptions,
33
+ TableSchema,
34
+ UpdateResult,
35
+ WhereCondition,
36
+ } from "./types"
37
+
38
+ // Re-export helper utilities
39
+ export {
40
+ column,
41
+ defaultExpr,
42
+ SQLiteFunctions,
43
+ SQLiteKeywords,
44
+ SQLiteTypes,
45
+ sql,
46
+ } from "./types"
47
+
48
+ /**
49
+ * TypedSQLite — comprehensive wrapper around bun:sqlite `Database`.
50
+ *
51
+ * This class provides full type safety for SQLite operations with support for:
52
+ * - All SQLite data types and variations
53
+ * - Built-in SQL functions
54
+ * - Complex constraints and relationships
55
+ * - Generated columns
56
+ * - Table-level constraints
57
+ * - JSON column support
58
+ * - And much more...
59
+ */
60
+ /**
61
+ * Auto-backup configuration options
62
+ */
63
+ export interface AutoBackupOptions {
64
+ /** Enable automatic backups */
65
+ enabled: boolean
66
+ /** Directory to store backup files */
67
+ directory: string
68
+ /** Backup interval in milliseconds (default: 1 hour) */
69
+ intervalMs?: number
70
+ /** Maximum number of backups to retain (default: 10) */
71
+ maxBackups?: number
72
+ /** Prefix for backup filenames (default: 'backup') */
73
+ filenamePrefix?: string
74
+ /** Whether to compress backups using gzip (default: false) */
75
+ compress?: boolean
76
+ }
77
+
78
+ /**
79
+ * Database configuration options
80
+ */
81
+ export interface DBOptions {
82
+ /** PRAGMA settings to apply on database open */
83
+ pragmas?: Array<[string, SQLQueryBindings]>
84
+ /** Paths to SQLite extensions to load */
85
+ loadExtensions?: string[]
86
+ /** Auto-backup configuration */
87
+ autoBackup?: AutoBackupOptions
88
+ }
89
+
90
+ class DB {
91
+ protected db: Database
92
+ protected dbPath: string
93
+ private autoBackupTimer: ReturnType<typeof setInterval> | null = null
94
+ private autoBackupOptions: AutoBackupOptions | null = null
95
+
96
+ /**
97
+ * Open or create a SQLite database at `path`.
98
+ *
99
+ * @param path - Path to the SQLite file (e.g. "app.db"). Use ":memory:" for in-memory DB.
100
+ * @param options - Optional database configuration
101
+ */
102
+ constructor(path: string, options?: DBOptions) {
103
+ dbLog.connection(path, "open")
104
+ this.dbPath = path
105
+ this.db = new Database(path)
106
+
107
+ // Apply PRAGMA settings if provided
108
+ if (options?.pragmas) {
109
+ for (const [name, value] of options.pragmas) {
110
+ this.pragma(name, value)
111
+ }
112
+ }
113
+
114
+ // Load extensions if provided
115
+ if (options?.loadExtensions) {
116
+ for (const extensionPath of options.loadExtensions) {
117
+ this.loadExtension(extensionPath)
118
+ }
119
+ }
120
+
121
+ // Setup auto-backup if configured
122
+ if (options?.autoBackup?.enabled) {
123
+ this.setupAutoBackup(options.autoBackup)
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Setup automatic backup with retention policy
129
+ */
130
+ private setupAutoBackup(options: AutoBackupOptions): void {
131
+ if (this.dbPath === ":memory:") {
132
+ backupLog.warn("Auto-backup is not available for in-memory databases")
133
+ return
134
+ }
135
+
136
+ this.autoBackupOptions = {
137
+ enabled: options.enabled,
138
+ directory: options.directory,
139
+ intervalMs: options.intervalMs ?? 60 * 60 * 1000, // Default: 1 hour
140
+ maxBackups: options.maxBackups ?? 10,
141
+ filenamePrefix: options.filenamePrefix ?? "backup",
142
+ compress: options.compress ?? false,
143
+ }
144
+
145
+ // Ensure backup directory exists
146
+ const fs = require("node:fs")
147
+ if (!fs.existsSync(this.autoBackupOptions.directory)) {
148
+ fs.mkdirSync(this.autoBackupOptions.directory, { recursive: true })
149
+ backupLog.info(`Created backup directory: ${this.autoBackupOptions.directory}`)
150
+ }
151
+
152
+ // Create initial backup
153
+ this.backup()
154
+
155
+ // Setup interval for periodic backups
156
+ this.autoBackupTimer = setInterval(() => {
157
+ this.backup()
158
+ }, this.autoBackupOptions.intervalMs)
159
+
160
+ backupLog.info(
161
+ `Auto-backup enabled: interval=${this.autoBackupOptions.intervalMs}ms, maxBackups=${this.autoBackupOptions.maxBackups}`
162
+ )
163
+ }
164
+
165
+ /**
166
+ * Create a backup of the database
167
+ *
168
+ * @param customPath - Optional custom path for the backup file. If not provided, uses auto-backup settings or generates a timestamped filename.
169
+ * @returns The path to the created backup file
170
+ */
171
+ backup(customPath?: string): string {
172
+ if (this.dbPath === ":memory:") {
173
+ throw new Error("Cannot backup an in-memory database")
174
+ }
175
+
176
+ const path = require("node:path")
177
+
178
+ let backupPath: string
179
+
180
+ if (customPath) {
181
+ backupPath = customPath
182
+ } else if (this.autoBackupOptions) {
183
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-")
184
+ const filename = `${this.autoBackupOptions.filenamePrefix}_${timestamp}.db`
185
+ backupPath = path.join(this.autoBackupOptions.directory, filename)
186
+ } else {
187
+ // Generate a default backup path next to the database file
188
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-")
189
+ const dir = path.dirname(this.dbPath)
190
+ const basename = path.basename(this.dbPath, path.extname(this.dbPath))
191
+ backupPath = path.join(dir, `${basename}_backup_${timestamp}.db`)
192
+ }
193
+
194
+ // Use SQLite's backup API via VACUUM INTO for a consistent backup
195
+ try {
196
+ this.db.run(`VACUUM INTO '${backupPath.replace(/'/g, "''")}'`)
197
+ backupLog.backup("create", backupPath)
198
+
199
+ // Apply retention policy if auto-backup is enabled
200
+ if (this.autoBackupOptions) {
201
+ this.applyRetentionPolicy()
202
+ }
203
+
204
+ return backupPath
205
+ } catch (error) {
206
+ backupLog.error(`Failed to create backup: ${error}`)
207
+ throw error
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Apply retention policy to remove old backups
213
+ */
214
+ private applyRetentionPolicy(): void {
215
+ if (!this.autoBackupOptions) return
216
+
217
+ const fs = require("node:fs")
218
+ const path = require("node:path")
219
+
220
+ const backupDir = this.autoBackupOptions.directory
221
+ const prefix = this.autoBackupOptions.filenamePrefix || "backup"
222
+ const maxBackups = this.autoBackupOptions.maxBackups || 10
223
+
224
+ try {
225
+ // Get all backup files matching the pattern
226
+ const files = fs
227
+ .readdirSync(backupDir)
228
+ .filter((file: string) => file.startsWith(prefix) && file.endsWith(".db"))
229
+ .map((file: string) => ({
230
+ name: file,
231
+ path: path.join(backupDir, file),
232
+ mtime: fs.statSync(path.join(backupDir, file)).mtime.getTime(),
233
+ }))
234
+ .sort((a: { mtime: number }, b: { mtime: number }) => b.mtime - a.mtime) // Sort by newest first
235
+
236
+ // Remove excess backups
237
+ if (files.length > maxBackups) {
238
+ const toDelete = files.slice(maxBackups)
239
+ for (const file of toDelete) {
240
+ fs.unlinkSync(file.path)
241
+ backupLog.debug(`Removed old backup: ${file.name}`)
242
+ }
243
+ backupLog.info(`Retention policy applied: removed ${toDelete.length} old backup(s)`)
244
+ }
245
+ } catch (error) {
246
+ backupLog.error(`Failed to apply retention policy: ${error}`)
247
+ }
248
+ }
249
+
250
+ /**
251
+ * List all available backups
252
+ *
253
+ * @returns Array of backup file information
254
+ */
255
+ listBackups(): Array<{ filename: string; path: string; size: number; created: Date }> {
256
+ if (!this.autoBackupOptions) {
257
+ backupLog.warn("Auto-backup is not configured. Use backup() with a custom path instead.")
258
+ return []
259
+ }
260
+
261
+ const fs = require("node:fs")
262
+ const path = require("node:path")
263
+
264
+ const backupDir = this.autoBackupOptions.directory
265
+ const prefix = this.autoBackupOptions.filenamePrefix || "backup"
266
+
267
+ try {
268
+ return fs
269
+ .readdirSync(backupDir)
270
+ .filter((file: string) => file.startsWith(prefix) && file.endsWith(".db"))
271
+ .map((file: string) => {
272
+ const filePath = path.join(backupDir, file)
273
+ const stats = fs.statSync(filePath)
274
+ return {
275
+ filename: file,
276
+ path: filePath,
277
+ size: stats.size,
278
+ created: stats.mtime,
279
+ }
280
+ })
281
+ .sort(
282
+ (a: { created: Date }, b: { created: Date }) => b.created.getTime() - a.created.getTime()
283
+ )
284
+ } catch (error) {
285
+ backupLog.error(`Failed to list backups: ${error}`)
286
+ return []
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Restore database from a backup file
292
+ *
293
+ * @param backupPath - Path to the backup file to restore from
294
+ * @param targetPath - Optional target path. If not provided, restores to the original database path.
295
+ */
296
+ restore(backupPath: string, targetPath?: string): void {
297
+ const fs = require("node:fs")
298
+
299
+ if (!fs.existsSync(backupPath)) {
300
+ throw new Error(`Backup file not found: ${backupPath}`)
301
+ }
302
+
303
+ const restorePath = targetPath || this.dbPath
304
+
305
+ if (restorePath === ":memory:") {
306
+ throw new Error("Cannot restore to an in-memory database path")
307
+ }
308
+
309
+ // Close current connection if restoring to the same path
310
+ if (restorePath === this.dbPath) {
311
+ this.db.close()
312
+ }
313
+
314
+ try {
315
+ fs.copyFileSync(backupPath, restorePath)
316
+ backupLog.backup("restore", backupPath)
317
+
318
+ // Reopen database if we closed it
319
+ if (restorePath === this.dbPath) {
320
+ this.db = new Database(this.dbPath)
321
+ dbLog.info("Database connection reopened after restore")
322
+ }
323
+ } catch (error) {
324
+ backupLog.error(`Failed to restore backup: ${error}`)
325
+ throw error
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Stop auto-backup if it's running
331
+ */
332
+ stopAutoBackup(): void {
333
+ if (this.autoBackupTimer) {
334
+ clearInterval(this.autoBackupTimer)
335
+ this.autoBackupTimer = null
336
+ backupLog.info("Auto-backup stopped")
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Get the database file path
342
+ */
343
+ getPath(): string {
344
+ return this.dbPath
345
+ }
346
+
347
+ /**
348
+ * Get a typed QueryBuilder for a given table name.
349
+ * (Documentation remains the same as before...)
350
+ */
351
+ table<T extends Record<string, unknown>>(
352
+ tableName: string,
353
+ parser: Partial<Parser<T>>
354
+ ): QueryBuilder<T> {
355
+ const pObj: Parser<T> = {
356
+ JSON: parser.JSON || [],
357
+ MODULE: parser.MODULE || {},
358
+ BOOLEAN: parser.BOOLEAN || [],
359
+ }
360
+
361
+ tableLog.debug(`Creating QueryBuilder for: ${tableName}`)
362
+ return new QueryBuilder<T>(this.db, tableName, pObj)
363
+ }
364
+
365
+ /**
366
+ * Close the underlying SQLite database handle.
367
+ * Also stops auto-backup if it's running.
368
+ */
369
+ close(): void {
370
+ dbLog.connection(this.dbPath, "close")
371
+ this.stopAutoBackup()
372
+ this.db.close()
373
+ }
374
+
375
+ /**
376
+ * Create a table with comprehensive type safety and feature support.
377
+ *
378
+ * Now supports all SQLite features:
379
+ *
380
+ * **Basic Usage:**
381
+ * ```ts
382
+ * import { column, sql } from "./db";
383
+ *
384
+ * db.createTable("users", {
385
+ * id: column.id(), // Auto-incrementing primary key
386
+ * email: column.varchar(255, { unique: true, notNull: true }),
387
+ * name: column.text({ notNull: true }),
388
+ * age: column.integer({ check: 'age >= 0 AND age <= 150' }),
389
+ * balance: column.numeric({ precision: 10, scale: 2, default: 0 }),
390
+ * is_active: column.boolean({ default: sql.true() }),
391
+ * metadata: column.json({ validateJson: true }),
392
+ * created_at: column.createdAt(),
393
+ * updated_at: column.updatedAt(),
394
+ * });
395
+ * ```
396
+ *
397
+ * **Advanced Features:**
398
+ * ```ts
399
+ * db.createTable("orders", {
400
+ * id: column.id(),
401
+ * order_number: column.varchar(50, {
402
+ * unique: true,
403
+ * default: sql.raw("'ORD-' || strftime('%Y%m%d', 'now') || '-' || substr(hex(randomblob(4)), 1, 8)")
404
+ * }),
405
+ * customer_id: column.foreignKey('users', 'id', {
406
+ * onDelete: 'CASCADE',
407
+ * onUpdate: 'RESTRICT'
408
+ * }),
409
+ * status: column.enum(['pending', 'paid', 'shipped', 'delivered'], {
410
+ * default: 'pending'
411
+ * }),
412
+ * total: column.numeric({ precision: 10, scale: 2, notNull: true }),
413
+ * // Generated column
414
+ * display_total: {
415
+ * type: 'TEXT',
416
+ * generated: {
417
+ * expression: "printf('$%.2f', total)",
418
+ * stored: false // VIRTUAL column
419
+ * }
420
+ * },
421
+ * }, {
422
+ * constraints: {
423
+ * check: ['total >= 0'],
424
+ * unique: [['customer_id', 'order_number']]
425
+ * }
426
+ * });
427
+ * ```
428
+ *
429
+ * **Date/Time Columns:**
430
+ * ```ts
431
+ * db.createTable("events", {
432
+ * id: column.id(),
433
+ * name: column.text({ notNull: true }),
434
+ * event_date: column.date({ notNull: true }),
435
+ * start_time: column.time(),
436
+ * created_at: column.timestamp({ default: sql.unixTimestamp() }),
437
+ * expires_at: column.datetime({
438
+ * default: sql.raw("datetime('now', '+1 year')")
439
+ * }),
440
+ * });
441
+ * ```
442
+ *
443
+ * **JSON and Advanced Types:**
444
+ * ```ts
445
+ * db.createTable("products", {
446
+ * id: column.uuid({ generateDefault: true }), // UUID primary key
447
+ * name: column.text({ notNull: true }),
448
+ * price: column.real({ check: 'price > 0' }),
449
+ * specifications: column.json({ validateJson: true }),
450
+ * tags: column.text(), // JSON array
451
+ * image_data: column.blob(),
452
+ * search_vector: {
453
+ * type: 'TEXT',
454
+ * generated: {
455
+ * expression: "lower(name || ' ' || coalesce(json_extract(specifications, '$.description'), ''))",
456
+ * stored: true // STORED for indexing
457
+ * }
458
+ * }
459
+ * });
460
+ * ```
461
+ *
462
+ * @param tableName - Table name to create.
463
+ * @param columns - Column definitions (string, legacy object, or type-safe schema).
464
+ * @param options - Table options including constraints and metadata.
465
+ *
466
+ * @throws {Error} If column definitions are invalid or constraints conflict.
467
+ */
468
+ createTable<_T extends Record<string, unknown> = Record<string, unknown>>(
469
+ tableName: string,
470
+ columns: Record<keyof _T, ColumnDefinition>,
471
+ options?: TableOptions<_T>
472
+ ): QueryBuilder<_T> {
473
+ const temp = options?.temporary ? "TEMPORARY " : tableName === ":memory" ? "TEMPORARY " : ""
474
+ const ifNot = options?.ifNotExists ? "IF NOT EXISTS " : ""
475
+ const withoutRowId = options?.withoutRowId ? " WITHOUT ROWID" : ""
476
+
477
+ const quoteIdent = (s: string) => `"${s.replace(/"/g, '""')}"`
478
+
479
+ let columnDefs: string
480
+ let tableConstraints: string[] = []
481
+
482
+ if (this.isTableSchema(columns)) {
483
+ // comprehensive type-safe approach
484
+ const parts: string[] = []
485
+ for (const [colName, colDef] of Object.entries(columns)) {
486
+ if (!colName) continue
487
+
488
+ const sqlDef = this.buildColumnSQL(colName, colDef)
489
+ parts.push(`${quoteIdent(colName)} ${sqlDef}`)
490
+ }
491
+
492
+ if (parts.length === 0) {
493
+ throw new Error("No columns provided")
494
+ }
495
+
496
+ columnDefs = parts.join(", ")
497
+
498
+ // Add table-level constraints
499
+ if (options?.constraints) {
500
+ tableConstraints = this.buildTableConstraints(options.constraints)
501
+ }
502
+ } else {
503
+ // Original object-based approach
504
+ const parts: string[] = []
505
+ for (const [col, def] of Object.entries(columns as Record<string, string>)) {
506
+ if (!col) continue
507
+
508
+ const defTrim = (def || "").trim()
509
+ if (!defTrim) {
510
+ throw new Error(`Missing SQL type/constraints for column "${col}"`)
511
+ }
512
+ parts.push(`${quoteIdent(col)} ${defTrim}`)
513
+ }
514
+
515
+ if (parts.length === 0) {
516
+ throw new Error("No columns provided")
517
+ }
518
+ columnDefs = parts.join(", ")
519
+ }
520
+
521
+ const allDefinitions = [columnDefs, ...tableConstraints].join(", ")
522
+
523
+ const columnNames = Object.keys(columns)
524
+ tableLog.tableCreate(tableName, columnNames)
525
+
526
+ const sql = `CREATE ${temp}TABLE ${ifNot}${quoteIdent(
527
+ tableName
528
+ )} (${allDefinitions})${withoutRowId};`
529
+
530
+ this.db.run(sql)
531
+
532
+ // Store table comment as metadata if provided
533
+ if (options?.comment) {
534
+ this.setTableComment(tableName, options.comment)
535
+ }
536
+
537
+ // Auto-detect JSON and BOOLEAN columns from schema
538
+ const autoDetectedJson: Array<keyof _T> = []
539
+ const autoDetectedBoolean: Array<keyof _T> = []
540
+
541
+ if (this.isTableSchema(columns)) {
542
+ for (const [colName, colDef] of Object.entries(columns)) {
543
+ if (colDef.type === "JSON") {
544
+ autoDetectedJson.push(colName as keyof _T)
545
+ }
546
+ if (colDef.type === "BOOLEAN") {
547
+ autoDetectedBoolean.push(colName as keyof _T)
548
+ }
549
+ }
550
+ }
551
+
552
+ // Merge auto-detected columns with user-provided parser options
553
+ const userJson = options?.parser?.JSON || []
554
+ const userBoolean = options?.parser?.BOOLEAN || []
555
+ const userModule = options?.parser?.MODULE || {}
556
+
557
+ // Combine and deduplicate
558
+ const mergedJson = [...new Set([...autoDetectedJson, ...userJson])] as Array<keyof _T>
559
+ const mergedBoolean = [...new Set([...autoDetectedBoolean, ...userBoolean])] as Array<keyof _T>
560
+
561
+ const pObj = {
562
+ JSON: mergedJson,
563
+ MODULE: userModule,
564
+ BOOLEAN: mergedBoolean,
565
+ }
566
+
567
+ tableLog.parserConfig(pObj.JSON.map(String), pObj.BOOLEAN.map(String), Object.keys(pObj.MODULE))
568
+
569
+ return this.table<_T>(tableName, pObj)
570
+ }
571
+
572
+ /**
573
+ * Create an index on a table
574
+ */
575
+ createIndex(
576
+ indexName: string,
577
+ tableName: string,
578
+ columns: string | string[],
579
+ options?: {
580
+ unique?: boolean
581
+ ifNotExists?: boolean
582
+ where?: string
583
+ partial?: string
584
+ }
585
+ ): void {
586
+ const unique = options?.unique ? "UNIQUE " : ""
587
+ const ifNot = options?.ifNotExists ? "IF NOT EXISTS " : ""
588
+ const quoteIdent = (s: string) => `"${s.replace(/"/g, '""')}"`
589
+
590
+ const columnList = Array.isArray(columns)
591
+ ? columns.map(quoteIdent).join(", ")
592
+ : quoteIdent(columns)
593
+
594
+ let sql = `CREATE ${unique}INDEX ${ifNot}${quoteIdent(
595
+ indexName
596
+ )} ON ${quoteIdent(tableName)} (${columnList})`
597
+
598
+ if (options?.where) {
599
+ sql += ` WHERE ${options.where}`
600
+ }
601
+
602
+ this.db.run(`${sql};`)
603
+ }
604
+
605
+ /**
606
+ * Drop a table
607
+ */
608
+ dropTable(tableName: string, options?: { ifExists?: boolean }): void {
609
+ const ifExists = options?.ifExists ? "IF EXISTS " : ""
610
+ const quoteIdent = (s: string) => `"${s.replace(/"/g, '""')}"`
611
+
612
+ const sql = `DROP TABLE ${ifExists}${quoteIdent(tableName)};`
613
+ this.db.run(sql)
614
+ }
615
+
616
+ /**
617
+ * Drop an index
618
+ */
619
+ dropIndex(indexName: string, options?: { ifExists?: boolean }): void {
620
+ const ifExists = options?.ifExists ? "IF EXISTS " : ""
621
+ const quoteIdent = (s: string) => `"${s.replace(/"/g, '""')}"`
622
+
623
+ const sql = `DROP INDEX ${ifExists}${quoteIdent(indexName)};`
624
+ this.db.run(sql)
625
+ }
626
+
627
+ /**
628
+ * Type guard to check if columns definition is a TableSchema
629
+ */
630
+ private isTableSchema(columns: unknown): columns is TableSchema {
631
+ if (typeof columns !== "object" || columns === null) {
632
+ return false
633
+ }
634
+
635
+ // Check if any value has a 'type' property with a valid SQLite type
636
+ for (const [_key, value] of Object.entries(columns)) {
637
+ if (typeof value === "object" && value !== null && "type" in value) {
638
+ const type = (value as { type: string }).type
639
+ const validTypes = [
640
+ "INTEGER",
641
+ "TEXT",
642
+ "REAL",
643
+ "BLOB",
644
+ "NUMERIC",
645
+ "INT",
646
+ "TINYINT",
647
+ "SMALLINT",
648
+ "MEDIUMINT",
649
+ "BIGINT",
650
+ "VARCHAR",
651
+ "CHAR",
652
+ "CHARACTER",
653
+ "NCHAR",
654
+ "NVARCHAR",
655
+ "CLOB",
656
+ "DOUBLE",
657
+ "FLOAT",
658
+ "DECIMAL",
659
+ "DATE",
660
+ "DATETIME",
661
+ "TIMESTAMP",
662
+ "TIME",
663
+ "BOOLEAN",
664
+ "JSON",
665
+ ]
666
+ if (validTypes.includes(type)) {
667
+ return true
668
+ }
669
+ }
670
+ }
671
+
672
+ return false
673
+ }
674
+
675
+ /**
676
+ * Build SQL column definition from ColumnDefinition object
677
+ */
678
+ private buildColumnSQL(columnName: string, colDef: ColumnDefinition): string {
679
+ const parts: string[] = []
680
+
681
+ // Add type with optional parameters
682
+ let typeStr = colDef.type
683
+ if (colDef.length) {
684
+ typeStr += `(${colDef.length})`
685
+ } else if (colDef.precision !== undefined) {
686
+ if (colDef.scale !== undefined) {
687
+ typeStr += `(${colDef.precision}, ${colDef.scale})`
688
+ } else {
689
+ typeStr += `(${colDef.precision})`
690
+ }
691
+ }
692
+ parts.push(typeStr)
693
+
694
+ // Add PRIMARY KEY (must come before AUTOINCREMENT)
695
+ if (colDef.primaryKey) {
696
+ parts.push("PRIMARY KEY")
697
+ }
698
+
699
+ // Add AUTOINCREMENT (only valid with INTEGER PRIMARY KEY)
700
+ if (colDef.autoincrement) {
701
+ if (!colDef.type.includes("INT") || !colDef.primaryKey) {
702
+ throw new Error(
703
+ `AUTOINCREMENT can only be used with INTEGER PRIMARY KEY columns (column: ${columnName})`
704
+ )
705
+ }
706
+ parts.push("AUTOINCREMENT")
707
+ }
708
+
709
+ // Add NOT NULL (but skip if PRIMARY KEY is already specified, as it's implicit)
710
+ if (colDef.notNull && !colDef.primaryKey) {
711
+ parts.push("NOT NULL")
712
+ }
713
+
714
+ // Add UNIQUE
715
+ if (colDef.unique) {
716
+ parts.push("UNIQUE")
717
+ }
718
+
719
+ // Add DEFAULT
720
+ if (colDef.default !== undefined) {
721
+ if (colDef.default === null) {
722
+ parts.push("DEFAULT NULL")
723
+ } else if (typeof colDef.default === "object" && colDef.default._type === "expression") {
724
+ // Handle DefaultExpression
725
+ parts.push(`DEFAULT (${colDef.default.expression})`)
726
+ } else if (typeof colDef.default === "string") {
727
+ // Handle string defaults - check if it's a function call or literal
728
+ if (this.isSQLFunction(colDef.default)) {
729
+ parts.push(`DEFAULT (${colDef.default})`)
730
+ } else {
731
+ // Literal string value
732
+ parts.push(`DEFAULT '${colDef.default.replace(/'/g, "''")}'`)
733
+ }
734
+ } else if (typeof colDef.default === "boolean") {
735
+ parts.push(`DEFAULT ${colDef.default ? 1 : 0}`)
736
+ } else {
737
+ parts.push(`DEFAULT ${colDef.default}`)
738
+ }
739
+ }
740
+
741
+ // Add COLLATE
742
+ if (colDef.collate) {
743
+ parts.push(`COLLATE ${colDef.collate}`)
744
+ }
745
+
746
+ // Add CHECK constraint (replace placeholder with actual column name)
747
+ if (colDef.check) {
748
+ const checkConstraint = colDef.check.replace(
749
+ /\{\{COLUMN\}\}/g,
750
+ `"${columnName.replace(/"/g, '""')}"`
751
+ )
752
+ parts.push(`CHECK (${checkConstraint})`)
753
+ }
754
+
755
+ // Add REFERENCES (foreign key)
756
+ if (colDef.references) {
757
+ const ref = colDef.references
758
+ let refClause = `REFERENCES "${ref.table.replace(
759
+ /"/g,
760
+ '""'
761
+ )}"("${ref.column.replace(/"/g, '""')}")`
762
+
763
+ if (ref.onDelete) {
764
+ refClause += ` ON DELETE ${ref.onDelete}`
765
+ }
766
+
767
+ if (ref.onUpdate) {
768
+ refClause += ` ON UPDATE ${ref.onUpdate}`
769
+ }
770
+
771
+ parts.push(refClause)
772
+ }
773
+
774
+ // Add GENERATED column
775
+ if (colDef.generated) {
776
+ const storageType = colDef.generated.stored ? "STORED" : "VIRTUAL"
777
+ parts.push(`GENERATED ALWAYS AS (${colDef.generated.expression}) ${storageType}`)
778
+ }
779
+ return parts.join(" ")
780
+ }
781
+
782
+ /**
783
+ * Build table-level constraints
784
+ */
785
+ private buildTableConstraints<T>(constraints: TableConstraints<T>): string[] {
786
+ const parts: string[] = []
787
+
788
+ // PRIMARY KEY constraint
789
+ if (constraints.primaryKey && constraints.primaryKey.length > 0) {
790
+ const columns = constraints.primaryKey
791
+ .map((col) => `"${String(col).replace(/"/g, '""')}"`)
792
+ .join(", ")
793
+ parts.push(`PRIMARY KEY (${columns})`)
794
+ }
795
+
796
+ // UNIQUE constraints
797
+ if (constraints.unique) {
798
+ if (Array.isArray(constraints.unique[0])) {
799
+ // Multiple composite unique constraints
800
+ for (const uniqueGroup of constraints.unique as string[][]) {
801
+ const columns = uniqueGroup.map((col) => `"${col.replace(/"/g, '""')}"`).join(", ")
802
+ parts.push(`UNIQUE (${columns})`)
803
+ }
804
+ } else {
805
+ // Single unique constraint
806
+ const columns = (constraints.unique as string[])
807
+ .map((col) => `"${col.replace(/"/g, '""')}"`)
808
+ .join(", ")
809
+ parts.push(`UNIQUE (${columns})`)
810
+ }
811
+ }
812
+
813
+ // CHECK constraints
814
+ if (constraints.check) {
815
+ for (const checkExpr of constraints.check) {
816
+ parts.push(`CHECK (${checkExpr})`)
817
+ }
818
+ }
819
+
820
+ // FOREIGN KEY constraints
821
+ if (constraints.foreignKeys) {
822
+ for (const fk of constraints.foreignKeys) {
823
+ const columns = fk.columns.map((col) => `"${String(col).replace(/"/g, '""')}"`).join(", ")
824
+ const refColumns = fk.references.columns
825
+ .map((col) => `"${col.replace(/"/g, '""')}"`)
826
+ .join(", ")
827
+
828
+ let fkClause = `FOREIGN KEY (${columns}) REFERENCES "${fk.references.table.replace(
829
+ /"/g,
830
+ '""'
831
+ )}" (${refColumns})`
832
+
833
+ if (fk.references.onDelete) {
834
+ fkClause += ` ON DELETE ${fk.references.onDelete}`
835
+ }
836
+
837
+ if (fk.references.onUpdate) {
838
+ fkClause += ` ON UPDATE ${fk.references.onUpdate}`
839
+ }
840
+
841
+ parts.push(fkClause)
842
+ }
843
+ }
844
+
845
+ return parts
846
+ }
847
+
848
+ /**
849
+ * Check if a string looks like a SQL function call
850
+ */
851
+ private isSQLFunction(str: string): boolean {
852
+ // Simple heuristic: contains parentheses and common SQL function patterns
853
+ const functionPatterns = [
854
+ /^\w+\s*\(/, // Function name followed by (
855
+ /^(datetime|date|time|strftime|current_timestamp|current_date|current_time)/i,
856
+ /^(random|abs|length|upper|lower|trim)/i,
857
+ /^(coalesce|ifnull|nullif|iif)/i,
858
+ /^(json|json_extract|json_valid)/i,
859
+ ]
860
+
861
+ return functionPatterns.some((pattern) => pattern.test(str.trim()))
862
+ }
863
+
864
+ /**
865
+ * Store table comment as metadata (using a system table if needed)
866
+ */
867
+ private setTableComment(tableName: string, comment: string): void {
868
+ // Create metadata table if it doesn't exist
869
+ try {
870
+ this.db.run(`
871
+ CREATE TABLE IF NOT EXISTS __table_metadata__ (
872
+ table_name TEXT PRIMARY KEY,
873
+ comment TEXT,
874
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
875
+ )
876
+ `)
877
+
878
+ // Insert or replace comment
879
+ const stmt = this.db.prepare(`
880
+ INSERT OR REPLACE INTO __table_metadata__ (table_name, comment, created_at)
881
+ VALUES (?, ?, CURRENT_TIMESTAMP)
882
+ `)
883
+ stmt.run(tableName, comment)
884
+ } catch (error) {
885
+ // Silently ignore if we can't create metadata table
886
+ logger.warn(`Could not store table comment for ${tableName}: ${error}`)
887
+ }
888
+ }
889
+
890
+ /**
891
+ * Get table comment from metadata
892
+ */
893
+ getTableComment(tableName: string): string | null {
894
+ try {
895
+ const stmt = this.db.prepare(`
896
+ SELECT comment FROM __table_metadata__ WHERE table_name = ?
897
+ `)
898
+ const result = stmt.get(tableName) as { comment: string } | undefined
899
+ return result?.comment || null
900
+ } catch (_error) {
901
+ return null
902
+ }
903
+ }
904
+
905
+ /**
906
+ * runute a raw SQL statement
907
+ */
908
+ run(sql: string): void {
909
+ logger.debug(`runuting SQL: ${sql}`)
910
+ this.db.run(sql)
911
+ }
912
+
913
+ /**
914
+ * Prepare a SQL statement for repeated runution
915
+ */
916
+ prepare(sql: string) {
917
+ return this.db.prepare(sql)
918
+ }
919
+
920
+ /**
921
+ * runute a transaction
922
+ */
923
+ transaction<T>(fn: () => T): T {
924
+ return this.db.transaction(fn)()
925
+ }
926
+
927
+ /**
928
+ * Begin a transaction manually
929
+ */
930
+ begin(mode?: "DEFERRED" | "IMMEDIATE" | "EXCLUSIVE"): void {
931
+ const modeStr = mode ? ` ${mode}` : ""
932
+ this.db.run(`BEGIN${modeStr}`)
933
+ }
934
+
935
+ /**
936
+ * Commit a transaction
937
+ */
938
+ commit(): void {
939
+ dbLog.transaction("commit")
940
+ this.run("COMMIT")
941
+ }
942
+
943
+ /**
944
+ * Rollback a transaction
945
+ */
946
+ rollback(): void {
947
+ dbLog.transaction("rollback")
948
+ this.run("ROLLBACK")
949
+ }
950
+
951
+ /**
952
+ * Create a savepoint
953
+ */
954
+ savepoint(name: string): void {
955
+ const quotedName = `"${name.replace(/"/g, '""')}"`
956
+ this.db.run(`SAVEPOINT ${quotedName}`)
957
+ }
958
+
959
+ /**
960
+ * Release a savepoint
961
+ */
962
+ releaseSavepoint(name: string): void {
963
+ const quotedName = `"${name.replace(/"/g, '""')}"`
964
+ this.db.run(`RELEASE SAVEPOINT ${quotedName}`)
965
+ }
966
+
967
+ /**
968
+ * Rollback to a savepoint
969
+ */
970
+ rollbackToSavepoint(name: string): void {
971
+ const quotedName = `"${name.replace(/"/g, '""')}"`
972
+ this.db.run(`ROLLBACK TO SAVEPOINT ${quotedName}`)
973
+ }
974
+
975
+ /**
976
+ * Vacuum the database (reclaim space and optimize)
977
+ */
978
+ vacuum() {
979
+ const result = this.db.run("VACUUM")
980
+ dbLog.debug("Vacuum completed")
981
+ return result
982
+ }
983
+
984
+ /**
985
+ * Analyze the database (update statistics for query optimizer)
986
+ */
987
+ analyze(tableName?: string): void {
988
+ if (tableName) {
989
+ const quotedName = `"${tableName.replace(/"/g, '""')}"`
990
+ this.db.run(`ANALYZE ${quotedName}`)
991
+ } else {
992
+ this.db.run("ANALYZE")
993
+ }
994
+ }
995
+
996
+ /**
997
+ * Check database integrity
998
+ */
999
+ integrityCheck(): Array<{ integrity_check: string }> {
1000
+ const stmt = this.db.prepare("PRAGMA integrity_check")
1001
+ return stmt.all() as Array<{ integrity_check: string }>
1002
+ }
1003
+
1004
+ /**
1005
+ * Get database schema information
1006
+ */
1007
+ getSchema(): Array<{ name: string; type: string; sql: string }> {
1008
+ const stmt = this.db.prepare(`
1009
+ SELECT name, type, sql
1010
+ FROM sqlite_master
1011
+ WHERE type IN ('table', 'index', 'view', 'trigger')
1012
+ ORDER BY type, name
1013
+ `)
1014
+ return stmt.all() as Array<{ name: string; type: string; sql: string }>
1015
+ }
1016
+
1017
+ /**
1018
+ * Get table info (columns, types, constraints)
1019
+ */
1020
+ getTableInfo(tableName: string): Array<{
1021
+ cid: number
1022
+ name: string
1023
+ type: string
1024
+ notnull: number
1025
+ dflt_value: SQLQueryBindings
1026
+ pk: number
1027
+ }> {
1028
+ const stmt = this.db.prepare(`PRAGMA table_info("${tableName.replace(/"/g, '""')}")`)
1029
+ return stmt.all() as Array<{
1030
+ cid: number
1031
+ name: string
1032
+ type: string
1033
+ notnull: number
1034
+ dflt_value: SQLQueryBindings
1035
+ pk: number
1036
+ }>
1037
+ }
1038
+
1039
+ /**
1040
+ * Get foreign key information for a table
1041
+ */
1042
+ getForeignKeys(tableName: string): Array<{
1043
+ id: number
1044
+ seq: number
1045
+ table: string
1046
+ from: string
1047
+ to: string
1048
+ on_update: string
1049
+ on_delete: string
1050
+ match: string
1051
+ }> {
1052
+ const stmt = this.db.prepare(`PRAGMA foreign_key_list("${tableName.replace(/"/g, '""')}")`)
1053
+ return stmt.all() as Array<{
1054
+ id: number
1055
+ seq: number
1056
+ table: string
1057
+ from: string
1058
+ to: string
1059
+ on_update: string
1060
+ on_delete: string
1061
+ match: string
1062
+ }>
1063
+ }
1064
+
1065
+ /**
1066
+ * Get index information for a table
1067
+ */
1068
+ getIndexes(tableName: string): Array<{
1069
+ name: string
1070
+ unique: number
1071
+ origin: string
1072
+ partial: number
1073
+ }> {
1074
+ const stmt = this.db.prepare(`PRAGMA index_list("${tableName.replace(/"/g, '""')}")`)
1075
+ return stmt.all() as Array<{
1076
+ name: string
1077
+ unique: number
1078
+ origin: string
1079
+ partial: number
1080
+ }>
1081
+ }
1082
+
1083
+ /**
1084
+ * Set or get a PRAGMA value.
1085
+ *
1086
+ * @param name - PRAGMA name (e.g., "foreign_keys", "journal_mode")
1087
+ * @param value - Value to set (omit to get current value)
1088
+ * @returns Current value when getting, undefined when setting
1089
+ */
1090
+ pragma(name: string, value?: SQLQueryBindings): SQLQueryBindings | undefined {
1091
+ if (value !== undefined) {
1092
+ this.db.run(`PRAGMA ${name} = ${value}`)
1093
+ return undefined
1094
+ }
1095
+ const result = this.db.prepare(`PRAGMA ${name}`).get() as Record<string, SQLQueryBindings>
1096
+ return Object.values(result)[0]
1097
+ }
1098
+
1099
+ /**
1100
+ * Load a SQLite extension.
1101
+ *
1102
+ * @param path - Absolute path to the compiled SQLite extension
1103
+ */
1104
+ loadExtension(path: string): void {
1105
+ this.db.loadExtension(path)
1106
+ }
1107
+
1108
+ /**
1109
+ * Get direct access to the underlying SQLite database instance.
1110
+ * Use this for advanced operations not covered by the wrapper.
1111
+ *
1112
+ * @returns The underlying Database instance
1113
+ */
1114
+ getDb(): Database {
1115
+ return this.db
1116
+ }
1117
+ }
1118
+
1119
+ export { DB }
1120
+ export default DB