@dockstat/sqlite-wrapper 1.1.3 → 1.2.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 ADDED
@@ -0,0 +1,838 @@
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
+ private 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(`Creating QueryBuilder for table: ${tableName}`)
106
+ return new QueryBuilder<T>(this.db, tableName, jsonConfig)
107
+ }
108
+
109
+ /**
110
+ * Close the underlying SQLite database handle.
111
+ */
112
+ close(): void {
113
+ logger.info('Closing database connection')
114
+ this.db.close()
115
+ }
116
+
117
+ /**
118
+ * Create a table with comprehensive type safety and feature support.
119
+ *
120
+ * Now supports all SQLite features:
121
+ *
122
+ * **Basic Usage:**
123
+ * ```ts
124
+ * import { column, sql } from "./db";
125
+ *
126
+ * db.createTable("users", {
127
+ * id: column.id(), // Auto-incrementing primary key
128
+ * email: column.varchar(255, { unique: true, notNull: true }),
129
+ * name: column.text({ notNull: true }),
130
+ * age: column.integer({ check: 'age >= 0 AND age <= 150' }),
131
+ * balance: column.numeric({ precision: 10, scale: 2, default: 0 }),
132
+ * is_active: column.boolean({ default: sql.true() }),
133
+ * metadata: column.json({ validateJson: true }),
134
+ * created_at: column.createdAt(),
135
+ * updated_at: column.updatedAt(),
136
+ * });
137
+ * ```
138
+ *
139
+ * **Advanced Features:**
140
+ * ```ts
141
+ * db.createTable("orders", {
142
+ * id: column.id(),
143
+ * order_number: column.varchar(50, {
144
+ * unique: true,
145
+ * default: sql.raw("'ORD-' || strftime('%Y%m%d', 'now') || '-' || substr(hex(randomblob(4)), 1, 8)")
146
+ * }),
147
+ * customer_id: column.foreignKey('users', 'id', {
148
+ * onDelete: 'CASCADE',
149
+ * onUpdate: 'RESTRICT'
150
+ * }),
151
+ * status: column.enum(['pending', 'paid', 'shipped', 'delivered'], {
152
+ * default: 'pending'
153
+ * }),
154
+ * total: column.numeric({ precision: 10, scale: 2, notNull: true }),
155
+ * // Generated column
156
+ * display_total: {
157
+ * type: 'TEXT',
158
+ * generated: {
159
+ * expression: "printf('$%.2f', total)",
160
+ * stored: false // VIRTUAL column
161
+ * }
162
+ * },
163
+ * }, {
164
+ * constraints: {
165
+ * check: ['total >= 0'],
166
+ * unique: [['customer_id', 'order_number']]
167
+ * }
168
+ * });
169
+ * ```
170
+ *
171
+ * **Date/Time Columns:**
172
+ * ```ts
173
+ * db.createTable("events", {
174
+ * id: column.id(),
175
+ * name: column.text({ notNull: true }),
176
+ * event_date: column.date({ notNull: true }),
177
+ * start_time: column.time(),
178
+ * created_at: column.timestamp({ default: sql.unixTimestamp() }),
179
+ * expires_at: column.datetime({
180
+ * default: sql.raw("datetime('now', '+1 year')")
181
+ * }),
182
+ * });
183
+ * ```
184
+ *
185
+ * **JSON and Advanced Types:**
186
+ * ```ts
187
+ * db.createTable("products", {
188
+ * id: column.uuid({ generateDefault: true }), // UUID primary key
189
+ * name: column.text({ notNull: true }),
190
+ * price: column.real({ check: 'price > 0' }),
191
+ * specifications: column.json({ validateJson: true }),
192
+ * tags: column.text(), // JSON array
193
+ * image_data: column.blob(),
194
+ * search_vector: {
195
+ * type: 'TEXT',
196
+ * generated: {
197
+ * expression: "lower(name || ' ' || coalesce(json_extract(specifications, '$.description'), ''))",
198
+ * stored: true // STORED for indexing
199
+ * }
200
+ * }
201
+ * });
202
+ * ```
203
+ *
204
+ * @param tableName - Table name to create.
205
+ * @param columns - Column definitions (string, legacy object, or type-safe schema).
206
+ * @param options - Table options including constraints and metadata.
207
+ *
208
+ * @throws {Error} If column definitions are invalid or constraints conflict.
209
+ */
210
+ createTable<_T extends Record<string, unknown>>(
211
+ tableName: string,
212
+ columns: string | Record<string, string> | TableSchema,
213
+ options?: TableOptions
214
+ ): void {
215
+ const temp = options?.temporary ? 'TEMPORARY ' : ''
216
+ const ifNot = options?.ifNotExists ? 'IF NOT EXISTS ' : ''
217
+ const withoutRowId = options?.withoutRowId ? ' WITHOUT ROWID' : ''
218
+
219
+ const quoteIdent = (s: string) => `"${s.replace(/"/g, '""')}"`
220
+
221
+ let columnDefs: string
222
+ let tableConstraints: string[] = []
223
+
224
+ if (typeof columns === 'string') {
225
+ // Original string-based approach
226
+ columnDefs = columns.trim()
227
+ if (!columnDefs) {
228
+ throw new Error('Empty column definition string')
229
+ }
230
+ } else if (this.isTableSchema(columns)) {
231
+ // New comprehensive type-safe approach
232
+ const parts: string[] = []
233
+ for (const [colName, colDef] of Object.entries(columns)) {
234
+ if (!colName) continue
235
+
236
+ const sqlDef = this.buildColumnSQL(colName, colDef)
237
+ parts.push(`${quoteIdent(colName)} ${sqlDef}`)
238
+ }
239
+
240
+ if (parts.length === 0) {
241
+ throw new Error('No columns provided')
242
+ }
243
+ columnDefs = parts.join(', ')
244
+
245
+ // Add table-level constraints
246
+ if (options?.constraints) {
247
+ tableConstraints = this.buildTableConstraints(options.constraints)
248
+ }
249
+ } else {
250
+ // Original object-based approach
251
+ const parts: string[] = []
252
+ for (const [col, def] of Object.entries(columns)) {
253
+ if (!col) continue
254
+
255
+ const defTrim = (def ?? '').trim()
256
+ if (!defTrim) {
257
+ throw new Error(`Missing SQL type/constraints for column "${col}"`)
258
+ }
259
+ parts.push(`${quoteIdent(col)} ${defTrim}`)
260
+ }
261
+
262
+ if (parts.length === 0) {
263
+ throw new Error('No columns provided')
264
+ }
265
+ columnDefs = parts.join(', ')
266
+ }
267
+
268
+ // Combine column definitions and table constraints
269
+ const allDefinitions = [columnDefs, ...tableConstraints].join(', ')
270
+
271
+ const sql = `CREATE ${temp}TABLE ${ifNot}${quoteIdent(tableName)} (${allDefinitions})${withoutRowId};`
272
+
273
+ this.db.run(sql)
274
+
275
+ // Store table comment as metadata if provided
276
+ if (options?.comment) {
277
+ this.setTableComment(tableName, options.comment)
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Create an index on a table
283
+ */
284
+ createIndex(
285
+ indexName: string,
286
+ tableName: string,
287
+ columns: string | string[],
288
+ options?: {
289
+ unique?: boolean
290
+ ifNotExists?: boolean
291
+ where?: string
292
+ partial?: string
293
+ }
294
+ ): void {
295
+ const unique = options?.unique ? 'UNIQUE ' : ''
296
+ const ifNot = options?.ifNotExists ? 'IF NOT EXISTS ' : ''
297
+ const quoteIdent = (s: string) => `"${s.replace(/"/g, '""')}"`
298
+
299
+ const columnList = Array.isArray(columns)
300
+ ? columns.map(quoteIdent).join(', ')
301
+ : quoteIdent(columns)
302
+
303
+ let sql = `CREATE ${unique}INDEX ${ifNot}${quoteIdent(indexName)} ON ${quoteIdent(tableName)} (${columnList})`
304
+
305
+ if (options?.where) {
306
+ sql += ` WHERE ${options.where}`
307
+ }
308
+
309
+ this.db.run(`${sql};`)
310
+ }
311
+
312
+ /**
313
+ * Drop a table
314
+ */
315
+ dropTable(tableName: string, options?: { ifExists?: boolean }): void {
316
+ const ifExists = options?.ifExists ? 'IF EXISTS ' : ''
317
+ const quoteIdent = (s: string) => `"${s.replace(/"/g, '""')}"`
318
+
319
+ const sql = `DROP TABLE ${ifExists}${quoteIdent(tableName)};`
320
+ this.db.run(sql)
321
+ }
322
+
323
+ /**
324
+ * Drop an index
325
+ */
326
+ dropIndex(indexName: string, options?: { ifExists?: boolean }): void {
327
+ const ifExists = options?.ifExists ? 'IF EXISTS ' : ''
328
+ const quoteIdent = (s: string) => `"${s.replace(/"/g, '""')}"`
329
+
330
+ const sql = `DROP INDEX ${ifExists}${quoteIdent(indexName)};`
331
+ this.db.run(sql)
332
+ }
333
+
334
+ /**
335
+ * Type guard to check if columns definition is a TableSchema
336
+ */
337
+ private isTableSchema(columns: unknown): columns is TableSchema {
338
+ if (typeof columns !== 'object' || columns === null) {
339
+ return false
340
+ }
341
+
342
+ // Check if any value has a 'type' property with a valid SQLite type
343
+ for (const [_key, value] of Object.entries(columns)) {
344
+ if (typeof value === 'object' && value !== null && 'type' in value) {
345
+ const type = (value as { type: string }).type
346
+ const validTypes = [
347
+ 'INTEGER',
348
+ 'TEXT',
349
+ 'REAL',
350
+ 'BLOB',
351
+ 'NUMERIC',
352
+ 'INT',
353
+ 'TINYINT',
354
+ 'SMALLINT',
355
+ 'MEDIUMINT',
356
+ 'BIGINT',
357
+ 'VARCHAR',
358
+ 'CHAR',
359
+ 'CHARACTER',
360
+ 'NCHAR',
361
+ 'NVARCHAR',
362
+ 'CLOB',
363
+ 'DOUBLE',
364
+ 'FLOAT',
365
+ 'DECIMAL',
366
+ 'DATE',
367
+ 'DATETIME',
368
+ 'TIMESTAMP',
369
+ 'TIME',
370
+ 'BOOLEAN',
371
+ 'JSON',
372
+ ]
373
+ if (validTypes.includes(type)) {
374
+ return true
375
+ }
376
+ }
377
+ }
378
+
379
+ return false
380
+ }
381
+
382
+ /**
383
+ * Build SQL column definition from ColumnDefinition object
384
+ */
385
+ private buildColumnSQL(columnName: string, colDef: ColumnDefinition): string {
386
+ const parts: string[] = []
387
+
388
+ // Add type with optional parameters
389
+ let typeStr = colDef.type
390
+ if (colDef.length) {
391
+ typeStr += `(${colDef.length})`
392
+ } else if (colDef.precision !== undefined) {
393
+ if (colDef.scale !== undefined) {
394
+ typeStr += `(${colDef.precision}, ${colDef.scale})`
395
+ } else {
396
+ typeStr += `(${colDef.precision})`
397
+ }
398
+ }
399
+ parts.push(typeStr)
400
+
401
+ // Add PRIMARY KEY (must come before AUTOINCREMENT)
402
+ if (colDef.primaryKey) {
403
+ parts.push('PRIMARY KEY')
404
+ }
405
+
406
+ // Add AUTOINCREMENT (only valid with INTEGER PRIMARY KEY)
407
+ if (colDef.autoincrement) {
408
+ if (!colDef.type.includes('INT') || !colDef.primaryKey) {
409
+ throw new Error(
410
+ `AUTOINCREMENT can only be used with INTEGER PRIMARY KEY columns (column: ${columnName})`
411
+ )
412
+ }
413
+ parts.push('AUTOINCREMENT')
414
+ }
415
+
416
+ // Add NOT NULL (but skip if PRIMARY KEY is already specified, as it's implicit)
417
+ if (colDef.notNull && !colDef.primaryKey) {
418
+ parts.push('NOT NULL')
419
+ }
420
+
421
+ // Add UNIQUE
422
+ if (colDef.unique) {
423
+ parts.push('UNIQUE')
424
+ }
425
+
426
+ // Add DEFAULT
427
+ if (colDef.default !== undefined) {
428
+ if (colDef.default === null) {
429
+ parts.push('DEFAULT NULL')
430
+ } else if (
431
+ typeof colDef.default === 'object' &&
432
+ colDef.default._type === 'expression'
433
+ ) {
434
+ // Handle DefaultExpression
435
+ parts.push(`DEFAULT (${colDef.default.expression})`)
436
+ } else if (typeof colDef.default === 'string') {
437
+ // Handle string defaults - check if it's a function call or literal
438
+ if (this.isSQLFunction(colDef.default)) {
439
+ parts.push(`DEFAULT (${colDef.default})`)
440
+ } else {
441
+ // Literal string value
442
+ parts.push(`DEFAULT '${colDef.default.replace(/'/g, "''")}'`)
443
+ }
444
+ } else if (typeof colDef.default === 'boolean') {
445
+ parts.push(`DEFAULT ${colDef.default ? 1 : 0}`)
446
+ } else {
447
+ parts.push(`DEFAULT ${colDef.default}`)
448
+ }
449
+ }
450
+
451
+ // Add COLLATE
452
+ if (colDef.collate) {
453
+ parts.push(`COLLATE ${colDef.collate}`)
454
+ }
455
+
456
+ // Add CHECK constraint (replace placeholder with actual column name)
457
+ if (colDef.check) {
458
+ const checkConstraint = colDef.check.replace(
459
+ /\{\{COLUMN\}\}/g,
460
+ `"${columnName.replace(/"/g, '""')}"`
461
+ )
462
+ parts.push(`CHECK (${checkConstraint})`)
463
+ }
464
+
465
+ // Add REFERENCES (foreign key)
466
+ if (colDef.references) {
467
+ const ref = colDef.references
468
+ let refClause = `REFERENCES "${ref.table.replace(/"/g, '""')}"("${ref.column.replace(/"/g, '""')}")`
469
+
470
+ if (ref.onDelete) {
471
+ refClause += ` ON DELETE ${ref.onDelete}`
472
+ }
473
+
474
+ if (ref.onUpdate) {
475
+ refClause += ` ON UPDATE ${ref.onUpdate}`
476
+ }
477
+
478
+ parts.push(refClause)
479
+ }
480
+
481
+ // Add GENERATED column
482
+ if (colDef.generated) {
483
+ const storageType = colDef.generated.stored ? 'STORED' : 'VIRTUAL'
484
+ parts.push(
485
+ `GENERATED ALWAYS AS (${colDef.generated.expression}) ${storageType}`
486
+ )
487
+ }
488
+
489
+ return parts.join(' ')
490
+ }
491
+
492
+ /**
493
+ * Build table-level constraints
494
+ */
495
+ private buildTableConstraints(constraints: TableConstraints): string[] {
496
+ const parts: string[] = []
497
+
498
+ // PRIMARY KEY constraint
499
+ if (constraints.primaryKey && constraints.primaryKey.length > 0) {
500
+ const columns = constraints.primaryKey
501
+ .map((col) => `"${col.replace(/"/g, '""')}"`)
502
+ .join(', ')
503
+ parts.push(`PRIMARY KEY (${columns})`)
504
+ }
505
+
506
+ // UNIQUE constraints
507
+ if (constraints.unique) {
508
+ if (Array.isArray(constraints.unique[0])) {
509
+ // Multiple composite unique constraints
510
+ for (const uniqueGroup of constraints.unique as string[][]) {
511
+ const columns = uniqueGroup
512
+ .map((col) => `"${col.replace(/"/g, '""')}"`)
513
+ .join(', ')
514
+ parts.push(`UNIQUE (${columns})`)
515
+ }
516
+ } else {
517
+ // Single unique constraint
518
+ const columns = (constraints.unique as string[])
519
+ .map((col) => `"${col.replace(/"/g, '""')}"`)
520
+ .join(', ')
521
+ parts.push(`UNIQUE (${columns})`)
522
+ }
523
+ }
524
+
525
+ // CHECK constraints
526
+ if (constraints.check) {
527
+ for (const checkExpr of constraints.check) {
528
+ parts.push(`CHECK (${checkExpr})`)
529
+ }
530
+ }
531
+
532
+ // FOREIGN KEY constraints
533
+ if (constraints.foreignKeys) {
534
+ for (const fk of constraints.foreignKeys) {
535
+ const columns = fk.columns
536
+ .map((col) => `"${col.replace(/"/g, '""')}"`)
537
+ .join(', ')
538
+ const refColumns = fk.references.columns
539
+ .map((col) => `"${col.replace(/"/g, '""')}"`)
540
+ .join(', ')
541
+
542
+ let fkClause = `FOREIGN KEY (${columns}) REFERENCES "${fk.references.table.replace(/"/g, '""')}" (${refColumns})`
543
+
544
+ if (fk.references.onDelete) {
545
+ fkClause += ` ON DELETE ${fk.references.onDelete}`
546
+ }
547
+
548
+ if (fk.references.onUpdate) {
549
+ fkClause += ` ON UPDATE ${fk.references.onUpdate}`
550
+ }
551
+
552
+ parts.push(fkClause)
553
+ }
554
+ }
555
+
556
+ return parts
557
+ }
558
+
559
+ /**
560
+ * Check if a string looks like a SQL function call
561
+ */
562
+ private isSQLFunction(str: string): boolean {
563
+ // Simple heuristic: contains parentheses and common SQL function patterns
564
+ const functionPatterns = [
565
+ /^\w+\s*\(/, // Function name followed by (
566
+ /^(datetime|date|time|strftime|current_timestamp|current_date|current_time)/i,
567
+ /^(random|abs|length|upper|lower|trim)/i,
568
+ /^(coalesce|ifnull|nullif|iif)/i,
569
+ /^(json|json_extract|json_valid)/i,
570
+ ]
571
+
572
+ return functionPatterns.some((pattern) => pattern.test(str.trim()))
573
+ }
574
+
575
+ /**
576
+ * Store table comment as metadata (using a system table if needed)
577
+ */
578
+ private setTableComment(tableName: string, comment: string): void {
579
+ // Create metadata table if it doesn't exist
580
+ try {
581
+ this.db.run(`
582
+ CREATE TABLE IF NOT EXISTS __table_metadata__ (
583
+ table_name TEXT PRIMARY KEY,
584
+ comment TEXT,
585
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
586
+ )
587
+ `)
588
+
589
+ // Insert or replace comment
590
+ const stmt = this.db.prepare(`
591
+ INSERT OR REPLACE INTO __table_metadata__ (table_name, comment, created_at)
592
+ VALUES (?, ?, CURRENT_TIMESTAMP)
593
+ `)
594
+ stmt.run(tableName, comment)
595
+ } catch (error) {
596
+ // Silently ignore if we can't create metadata table
597
+ console.warn(`Could not store table comment for ${tableName}:`, error)
598
+ }
599
+ }
600
+
601
+ /**
602
+ * Get table comment from metadata
603
+ */
604
+ getTableComment(tableName: string): string | null {
605
+ try {
606
+ const stmt = this.db.prepare(`
607
+ SELECT comment FROM __table_metadata__ WHERE table_name = ?
608
+ `)
609
+ const result = stmt.get(tableName) as { comment: string } | undefined
610
+ return result?.comment || null
611
+ } catch (_error) {
612
+ return null
613
+ }
614
+ }
615
+
616
+ /**
617
+ * runute a raw SQL statement
618
+ */
619
+ run(sql: string): void {
620
+ logger.debug(`runuting SQL: ${sql}`)
621
+ this.db.run(sql)
622
+ }
623
+
624
+ /**
625
+ * Prepare a SQL statement for repeated runution
626
+ */
627
+ prepare(sql: string) {
628
+ return this.db.prepare(sql)
629
+ }
630
+
631
+ /**
632
+ * runute a transaction
633
+ */
634
+ transaction<T>(fn: () => T): T {
635
+ return this.db.transaction(fn)()
636
+ }
637
+
638
+ /**
639
+ * Begin a transaction manually
640
+ */
641
+ begin(mode?: 'DEFERRED' | 'IMMEDIATE' | 'EXCLUSIVE'): void {
642
+ const modeStr = mode ? ` ${mode}` : ''
643
+ this.db.run(`BEGIN${modeStr}`)
644
+ }
645
+
646
+ /**
647
+ * Commit a transaction
648
+ */
649
+ commit(): void {
650
+ logger.debug('Committing transaction')
651
+ this.run('COMMIT')
652
+ }
653
+
654
+ /**
655
+ * Rollback a transaction
656
+ */
657
+ rollback(): void {
658
+ logger.warn('Rolling back transaction')
659
+ this.run('ROLLBACK')
660
+ }
661
+
662
+ /**
663
+ * Create a savepoint
664
+ */
665
+ savepoint(name: string): void {
666
+ const quotedName = `"${name.replace(/"/g, '""')}"`
667
+ this.db.run(`SAVEPOINT ${quotedName}`)
668
+ }
669
+
670
+ /**
671
+ * Release a savepoint
672
+ */
673
+ releaseSavepoint(name: string): void {
674
+ const quotedName = `"${name.replace(/"/g, '""')}"`
675
+ this.db.run(`RELEASE SAVEPOINT ${quotedName}`)
676
+ }
677
+
678
+ /**
679
+ * Rollback to a savepoint
680
+ */
681
+ rollbackToSavepoint(name: string): void {
682
+ const quotedName = `"${name.replace(/"/g, '""')}"`
683
+ this.db.run(`ROLLBACK TO SAVEPOINT ${quotedName}`)
684
+ }
685
+
686
+ /**
687
+ * Vacuum the database (reclaim space and optimize)
688
+ */
689
+ vacuum(): void {
690
+ this.db.run('VACUUM')
691
+ }
692
+
693
+ /**
694
+ * Analyze the database (update statistics for query optimizer)
695
+ */
696
+ analyze(tableName?: string): void {
697
+ if (tableName) {
698
+ const quotedName = `"${tableName.replace(/"/g, '""')}"`
699
+ this.db.run(`ANALYZE ${quotedName}`)
700
+ } else {
701
+ this.db.run('ANALYZE')
702
+ }
703
+ }
704
+
705
+ /**
706
+ * Check database integrity
707
+ */
708
+ integrityCheck(): Array<{ integrity_check: string }> {
709
+ const stmt = this.db.prepare('PRAGMA integrity_check')
710
+ return stmt.all() as Array<{ integrity_check: string }>
711
+ }
712
+
713
+ /**
714
+ * Get database schema information
715
+ */
716
+ getSchema(): Array<{ name: string; type: string; sql: string }> {
717
+ const stmt = this.db.prepare(`
718
+ SELECT name, type, sql
719
+ FROM sqlite_master
720
+ WHERE type IN ('table', 'index', 'view', 'trigger')
721
+ ORDER BY type, name
722
+ `)
723
+ return stmt.all() as Array<{ name: string; type: string; sql: string }>
724
+ }
725
+
726
+ /**
727
+ * Get table info (columns, types, constraints)
728
+ */
729
+ getTableInfo(tableName: string): Array<{
730
+ cid: number
731
+ name: string
732
+ type: string
733
+ notnull: number
734
+ dflt_value: SQLQueryBindings
735
+ pk: number
736
+ }> {
737
+ const stmt = this.db.prepare(
738
+ `PRAGMA table_info("${tableName.replace(/"/g, '""')}")`
739
+ )
740
+ return stmt.all() as Array<{
741
+ cid: number
742
+ name: string
743
+ type: string
744
+ notnull: number
745
+ dflt_value: SQLQueryBindings
746
+ pk: number
747
+ }>
748
+ }
749
+
750
+ /**
751
+ * Get foreign key information for a table
752
+ */
753
+ getForeignKeys(tableName: string): Array<{
754
+ id: number
755
+ seq: number
756
+ table: string
757
+ from: string
758
+ to: string
759
+ on_update: string
760
+ on_delete: string
761
+ match: string
762
+ }> {
763
+ const stmt = this.db.prepare(
764
+ `PRAGMA foreign_key_list("${tableName.replace(/"/g, '""')}")`
765
+ )
766
+ return stmt.all() as Array<{
767
+ id: number
768
+ seq: number
769
+ table: string
770
+ from: string
771
+ to: string
772
+ on_update: string
773
+ on_delete: string
774
+ match: string
775
+ }>
776
+ }
777
+
778
+ /**
779
+ * Get index information for a table
780
+ */
781
+ getIndexes(tableName: string): Array<{
782
+ name: string
783
+ unique: number
784
+ origin: string
785
+ partial: number
786
+ }> {
787
+ const stmt = this.db.prepare(
788
+ `PRAGMA index_list("${tableName.replace(/"/g, '""')}")`
789
+ )
790
+ return stmt.all() as Array<{
791
+ name: string
792
+ unique: number
793
+ origin: string
794
+ partial: number
795
+ }>
796
+ }
797
+
798
+ /**
799
+ * Set or get a PRAGMA value.
800
+ *
801
+ * @param name - PRAGMA name (e.g., "foreign_keys", "journal_mode")
802
+ * @param value - Value to set (omit to get current value)
803
+ * @returns Current value when getting, undefined when setting
804
+ */
805
+ pragma(name: string, value?: SQLQueryBindings): SQLQueryBindings | undefined {
806
+ if (value !== undefined) {
807
+ this.db.run(`PRAGMA ${name} = ${value}`)
808
+ return undefined
809
+ }
810
+ const result = this.db.prepare(`PRAGMA ${name}`).get() as Record<
811
+ string,
812
+ SQLQueryBindings
813
+ >
814
+ return Object.values(result)[0]
815
+ }
816
+
817
+ /**
818
+ * Load a SQLite extension.
819
+ *
820
+ * @param path - Absolute path to the compiled SQLite extension
821
+ */
822
+ loadExtension(path: string): void {
823
+ this.db.loadExtension(path)
824
+ }
825
+
826
+ /**
827
+ * Get direct access to the underlying SQLite database instance.
828
+ * Use this for advanced operations not covered by the wrapper.
829
+ *
830
+ * @returns The underlying Database instance
831
+ */
832
+ getDb(): Database {
833
+ return this.db
834
+ }
835
+ }
836
+
837
+ export { DB }
838
+ export default DB