@dockstat/sqlite-wrapper 1.2.8 → 1.3.1

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