@dockstat/sqlite-wrapper 1.2.6 → 1.2.8

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,840 +1,858 @@
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} - JSONConfig: ${JSON.stringify(jsonConfig)}`)
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> | Partial<Record<Extract<keyof _T, string>, ColumnDefinition>> | TableSchema,
213
- options?: TableOptions<_T>,
214
- ): QueryBuilder<_T> {
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
- return this.table<_T>(tableName, options?.jsonConfig)
281
- }
282
-
283
- /**
284
- * Create an index on a table
285
- */
286
- createIndex(
287
- indexName: string,
288
- tableName: string,
289
- columns: string | string[],
290
- options?: {
291
- unique?: boolean
292
- ifNotExists?: boolean
293
- where?: string
294
- partial?: string
295
- }
296
- ): void {
297
- const unique = options?.unique ? 'UNIQUE ' : ''
298
- const ifNot = options?.ifNotExists ? 'IF NOT EXISTS ' : ''
299
- const quoteIdent = (s: string) => `"${s.replace(/"/g, '""')}"`
300
-
301
- const columnList = Array.isArray(columns)
302
- ? columns.map(quoteIdent).join(', ')
303
- : quoteIdent(columns)
304
-
305
- let sql = `CREATE ${unique}INDEX ${ifNot}${quoteIdent(indexName)} ON ${quoteIdent(tableName)} (${columnList})`
306
-
307
- if (options?.where) {
308
- sql += ` WHERE ${options.where}`
309
- }
310
-
311
- this.db.run(`${sql};`)
312
- }
313
-
314
- /**
315
- * Drop a table
316
- */
317
- dropTable(tableName: string, options?: { ifExists?: boolean }): void {
318
- const ifExists = options?.ifExists ? 'IF EXISTS ' : ''
319
- const quoteIdent = (s: string) => `"${s.replace(/"/g, '""')}"`
320
-
321
- const sql = `DROP TABLE ${ifExists}${quoteIdent(tableName)};`
322
- this.db.run(sql)
323
- }
324
-
325
- /**
326
- * Drop an index
327
- */
328
- dropIndex(indexName: string, options?: { ifExists?: boolean }): void {
329
- const ifExists = options?.ifExists ? 'IF EXISTS ' : ''
330
- const quoteIdent = (s: string) => `"${s.replace(/"/g, '""')}"`
331
-
332
- const sql = `DROP INDEX ${ifExists}${quoteIdent(indexName)};`
333
- this.db.run(sql)
334
- }
335
-
336
- /**
337
- * Type guard to check if columns definition is a TableSchema
338
- */
339
- private isTableSchema(columns: unknown): columns is TableSchema {
340
- if (typeof columns !== 'object' || columns === null) {
341
- return false
342
- }
343
-
344
- // Check if any value has a 'type' property with a valid SQLite type
345
- for (const [_key, value] of Object.entries(columns)) {
346
- if (typeof value === 'object' && value !== null && 'type' in value) {
347
- const type = (value as { type: string }).type
348
- const validTypes = [
349
- 'INTEGER',
350
- 'TEXT',
351
- 'REAL',
352
- 'BLOB',
353
- 'NUMERIC',
354
- 'INT',
355
- 'TINYINT',
356
- 'SMALLINT',
357
- 'MEDIUMINT',
358
- 'BIGINT',
359
- 'VARCHAR',
360
- 'CHAR',
361
- 'CHARACTER',
362
- 'NCHAR',
363
- 'NVARCHAR',
364
- 'CLOB',
365
- 'DOUBLE',
366
- 'FLOAT',
367
- 'DECIMAL',
368
- 'DATE',
369
- 'DATETIME',
370
- 'TIMESTAMP',
371
- 'TIME',
372
- 'BOOLEAN',
373
- 'JSON',
374
- ]
375
- if (validTypes.includes(type)) {
376
- return true
377
- }
378
- }
379
- }
380
-
381
- return false
382
- }
383
-
384
- /**
385
- * Build SQL column definition from ColumnDefinition object
386
- */
387
- private buildColumnSQL(columnName: string, colDef: ColumnDefinition): string {
388
- const parts: string[] = []
389
-
390
- // Add type with optional parameters
391
- let typeStr = colDef.type
392
- if (colDef.length) {
393
- typeStr += `(${colDef.length})`
394
- } else if (colDef.precision !== undefined) {
395
- if (colDef.scale !== undefined) {
396
- typeStr += `(${colDef.precision}, ${colDef.scale})`
397
- } else {
398
- typeStr += `(${colDef.precision})`
399
- }
400
- }
401
- parts.push(typeStr)
402
-
403
- // Add PRIMARY KEY (must come before AUTOINCREMENT)
404
- if (colDef.primaryKey) {
405
- parts.push('PRIMARY KEY')
406
- }
407
-
408
- // Add AUTOINCREMENT (only valid with INTEGER PRIMARY KEY)
409
- if (colDef.autoincrement) {
410
- if (!colDef.type.includes('INT') || !colDef.primaryKey) {
411
- throw new Error(
412
- `AUTOINCREMENT can only be used with INTEGER PRIMARY KEY columns (column: ${columnName})`
413
- )
414
- }
415
- parts.push('AUTOINCREMENT')
416
- }
417
-
418
- // Add NOT NULL (but skip if PRIMARY KEY is already specified, as it's implicit)
419
- if (colDef.notNull && !colDef.primaryKey) {
420
- parts.push('NOT NULL')
421
- }
422
-
423
- // Add UNIQUE
424
- if (colDef.unique) {
425
- parts.push('UNIQUE')
426
- }
427
-
428
- // Add DEFAULT
429
- if (colDef.default !== undefined) {
430
- if (colDef.default === null) {
431
- parts.push('DEFAULT NULL')
432
- } else if (
433
- typeof colDef.default === 'object' &&
434
- colDef.default._type === 'expression'
435
- ) {
436
- // Handle DefaultExpression
437
- parts.push(`DEFAULT (${colDef.default.expression})`)
438
- } else if (typeof colDef.default === 'string') {
439
- // Handle string defaults - check if it's a function call or literal
440
- if (this.isSQLFunction(colDef.default)) {
441
- parts.push(`DEFAULT (${colDef.default})`)
442
- } else {
443
- // Literal string value
444
- parts.push(`DEFAULT '${colDef.default.replace(/'/g, "''")}'`)
445
- }
446
- } else if (typeof colDef.default === 'boolean') {
447
- parts.push(`DEFAULT ${colDef.default ? 1 : 0}`)
448
- } else {
449
- parts.push(`DEFAULT ${colDef.default}`)
450
- }
451
- }
452
-
453
- // Add COLLATE
454
- if (colDef.collate) {
455
- parts.push(`COLLATE ${colDef.collate}`)
456
- }
457
-
458
- // Add CHECK constraint (replace placeholder with actual column name)
459
- if (colDef.check) {
460
- const checkConstraint = colDef.check.replace(
461
- /\{\{COLUMN\}\}/g,
462
- `"${columnName.replace(/"/g, '""')}"`
463
- )
464
- parts.push(`CHECK (${checkConstraint})`)
465
- }
466
-
467
- // Add REFERENCES (foreign key)
468
- if (colDef.references) {
469
- const ref = colDef.references
470
- let refClause = `REFERENCES "${ref.table.replace(/"/g, '""')}"("${ref.column.replace(/"/g, '""')}")`
471
-
472
- if (ref.onDelete) {
473
- refClause += ` ON DELETE ${ref.onDelete}`
474
- }
475
-
476
- if (ref.onUpdate) {
477
- refClause += ` ON UPDATE ${ref.onUpdate}`
478
- }
479
-
480
- parts.push(refClause)
481
- }
482
-
483
- // Add GENERATED column
484
- if (colDef.generated) {
485
- const storageType = colDef.generated.stored ? 'STORED' : 'VIRTUAL'
486
- parts.push(
487
- `GENERATED ALWAYS AS (${colDef.generated.expression}) ${storageType}`
488
- )
489
- }
490
-
491
- return parts.join(' ')
492
- }
493
-
494
- /**
495
- * Build table-level constraints
496
- */
497
- private buildTableConstraints(constraints: TableConstraints): string[] {
498
- const parts: string[] = []
499
-
500
- // PRIMARY KEY constraint
501
- if (constraints.primaryKey && constraints.primaryKey.length > 0) {
502
- const columns = constraints.primaryKey
503
- .map((col) => `"${col.replace(/"/g, '""')}"`)
504
- .join(', ')
505
- parts.push(`PRIMARY KEY (${columns})`)
506
- }
507
-
508
- // UNIQUE constraints
509
- if (constraints.unique) {
510
- if (Array.isArray(constraints.unique[0])) {
511
- // Multiple composite unique constraints
512
- for (const uniqueGroup of constraints.unique as string[][]) {
513
- const columns = uniqueGroup
514
- .map((col) => `"${col.replace(/"/g, '""')}"`)
515
- .join(', ')
516
- parts.push(`UNIQUE (${columns})`)
517
- }
518
- } else {
519
- // Single unique constraint
520
- const columns = (constraints.unique as string[])
521
- .map((col) => `"${col.replace(/"/g, '""')}"`)
522
- .join(', ')
523
- parts.push(`UNIQUE (${columns})`)
524
- }
525
- }
526
-
527
- // CHECK constraints
528
- if (constraints.check) {
529
- for (const checkExpr of constraints.check) {
530
- parts.push(`CHECK (${checkExpr})`)
531
- }
532
- }
533
-
534
- // FOREIGN KEY constraints
535
- if (constraints.foreignKeys) {
536
- for (const fk of constraints.foreignKeys) {
537
- const columns = fk.columns
538
- .map((col) => `"${col.replace(/"/g, '""')}"`)
539
- .join(', ')
540
- const refColumns = fk.references.columns
541
- .map((col) => `"${col.replace(/"/g, '""')}"`)
542
- .join(', ')
543
-
544
- let fkClause = `FOREIGN KEY (${columns}) REFERENCES "${fk.references.table.replace(/"/g, '""')}" (${refColumns})`
545
-
546
- if (fk.references.onDelete) {
547
- fkClause += ` ON DELETE ${fk.references.onDelete}`
548
- }
549
-
550
- if (fk.references.onUpdate) {
551
- fkClause += ` ON UPDATE ${fk.references.onUpdate}`
552
- }
553
-
554
- parts.push(fkClause)
555
- }
556
- }
557
-
558
- return parts
559
- }
560
-
561
- /**
562
- * Check if a string looks like a SQL function call
563
- */
564
- private isSQLFunction(str: string): boolean {
565
- // Simple heuristic: contains parentheses and common SQL function patterns
566
- const functionPatterns = [
567
- /^\w+\s*\(/, // Function name followed by (
568
- /^(datetime|date|time|strftime|current_timestamp|current_date|current_time)/i,
569
- /^(random|abs|length|upper|lower|trim)/i,
570
- /^(coalesce|ifnull|nullif|iif)/i,
571
- /^(json|json_extract|json_valid)/i,
572
- ]
573
-
574
- return functionPatterns.some((pattern) => pattern.test(str.trim()))
575
- }
576
-
577
- /**
578
- * Store table comment as metadata (using a system table if needed)
579
- */
580
- private setTableComment(tableName: string, comment: string): void {
581
- // Create metadata table if it doesn't exist
582
- try {
583
- this.db.run(`
584
- CREATE TABLE IF NOT EXISTS __table_metadata__ (
585
- table_name TEXT PRIMARY KEY,
586
- comment TEXT,
587
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
588
- )
589
- `)
590
-
591
- // Insert or replace comment
592
- const stmt = this.db.prepare(`
593
- INSERT OR REPLACE INTO __table_metadata__ (table_name, comment, created_at)
594
- VALUES (?, ?, CURRENT_TIMESTAMP)
595
- `)
596
- stmt.run(tableName, comment)
597
- } catch (error) {
598
- // Silently ignore if we can't create metadata table
599
- console.warn(`Could not store table comment for ${tableName}:`, error)
600
- }
601
- }
602
-
603
- /**
604
- * Get table comment from metadata
605
- */
606
- getTableComment(tableName: string): string | null {
607
- try {
608
- const stmt = this.db.prepare(`
609
- SELECT comment FROM __table_metadata__ WHERE table_name = ?
610
- `)
611
- const result = stmt.get(tableName) as { comment: string } | undefined
612
- return result?.comment || null
613
- } catch (_error) {
614
- return null
615
- }
616
- }
617
-
618
- /**
619
- * runute a raw SQL statement
620
- */
621
- run(sql: string): void {
622
- logger.debug(`runuting SQL: ${sql}`)
623
- this.db.run(sql)
624
- }
625
-
626
- /**
627
- * Prepare a SQL statement for repeated runution
628
- */
629
- prepare(sql: string) {
630
- return this.db.prepare(sql)
631
- }
632
-
633
- /**
634
- * runute a transaction
635
- */
636
- transaction<T>(fn: () => T): T {
637
- return this.db.transaction(fn)()
638
- }
639
-
640
- /**
641
- * Begin a transaction manually
642
- */
643
- begin(mode?: 'DEFERRED' | 'IMMEDIATE' | 'EXCLUSIVE'): void {
644
- const modeStr = mode ? ` ${mode}` : ''
645
- this.db.run(`BEGIN${modeStr}`)
646
- }
647
-
648
- /**
649
- * Commit a transaction
650
- */
651
- commit(): void {
652
- logger.debug('Committing transaction')
653
- this.run('COMMIT')
654
- }
655
-
656
- /**
657
- * Rollback a transaction
658
- */
659
- rollback(): void {
660
- logger.warn('Rolling back transaction')
661
- this.run('ROLLBACK')
662
- }
663
-
664
- /**
665
- * Create a savepoint
666
- */
667
- savepoint(name: string): void {
668
- const quotedName = `"${name.replace(/"/g, '""')}"`
669
- this.db.run(`SAVEPOINT ${quotedName}`)
670
- }
671
-
672
- /**
673
- * Release a savepoint
674
- */
675
- releaseSavepoint(name: string): void {
676
- const quotedName = `"${name.replace(/"/g, '""')}"`
677
- this.db.run(`RELEASE SAVEPOINT ${quotedName}`)
678
- }
679
-
680
- /**
681
- * Rollback to a savepoint
682
- */
683
- rollbackToSavepoint(name: string): void {
684
- const quotedName = `"${name.replace(/"/g, '""')}"`
685
- this.db.run(`ROLLBACK TO SAVEPOINT ${quotedName}`)
686
- }
687
-
688
- /**
689
- * Vacuum the database (reclaim space and optimize)
690
- */
691
- vacuum(): void {
692
- this.db.run('VACUUM')
693
- }
694
-
695
- /**
696
- * Analyze the database (update statistics for query optimizer)
697
- */
698
- analyze(tableName?: string): void {
699
- if (tableName) {
700
- const quotedName = `"${tableName.replace(/"/g, '""')}"`
701
- this.db.run(`ANALYZE ${quotedName}`)
702
- } else {
703
- this.db.run('ANALYZE')
704
- }
705
- }
706
-
707
- /**
708
- * Check database integrity
709
- */
710
- integrityCheck(): Array<{ integrity_check: string }> {
711
- const stmt = this.db.prepare('PRAGMA integrity_check')
712
- return stmt.all() as Array<{ integrity_check: string }>
713
- }
714
-
715
- /**
716
- * Get database schema information
717
- */
718
- getSchema(): Array<{ name: string; type: string; sql: string }> {
719
- const stmt = this.db.prepare(`
720
- SELECT name, type, sql
721
- FROM sqlite_master
722
- WHERE type IN ('table', 'index', 'view', 'trigger')
723
- ORDER BY type, name
724
- `)
725
- return stmt.all() as Array<{ name: string; type: string; sql: string }>
726
- }
727
-
728
- /**
729
- * Get table info (columns, types, constraints)
730
- */
731
- getTableInfo(tableName: string): Array<{
732
- cid: number
733
- name: string
734
- type: string
735
- notnull: number
736
- dflt_value: SQLQueryBindings
737
- pk: number
738
- }> {
739
- const stmt = this.db.prepare(
740
- `PRAGMA table_info("${tableName.replace(/"/g, '""')}")`
741
- )
742
- return stmt.all() as Array<{
743
- cid: number
744
- name: string
745
- type: string
746
- notnull: number
747
- dflt_value: SQLQueryBindings
748
- pk: number
749
- }>
750
- }
751
-
752
- /**
753
- * Get foreign key information for a table
754
- */
755
- getForeignKeys(tableName: string): Array<{
756
- id: number
757
- seq: number
758
- table: string
759
- from: string
760
- to: string
761
- on_update: string
762
- on_delete: string
763
- match: string
764
- }> {
765
- const stmt = this.db.prepare(
766
- `PRAGMA foreign_key_list("${tableName.replace(/"/g, '""')}")`
767
- )
768
- return stmt.all() as Array<{
769
- id: number
770
- seq: number
771
- table: string
772
- from: string
773
- to: string
774
- on_update: string
775
- on_delete: string
776
- match: string
777
- }>
778
- }
779
-
780
- /**
781
- * Get index information for a table
782
- */
783
- getIndexes(tableName: string): Array<{
784
- name: string
785
- unique: number
786
- origin: string
787
- partial: number
788
- }> {
789
- const stmt = this.db.prepare(
790
- `PRAGMA index_list("${tableName.replace(/"/g, '""')}")`
791
- )
792
- return stmt.all() as Array<{
793
- name: string
794
- unique: number
795
- origin: string
796
- partial: number
797
- }>
798
- }
799
-
800
- /**
801
- * Set or get a PRAGMA value.
802
- *
803
- * @param name - PRAGMA name (e.g., "foreign_keys", "journal_mode")
804
- * @param value - Value to set (omit to get current value)
805
- * @returns Current value when getting, undefined when setting
806
- */
807
- pragma(name: string, value?: SQLQueryBindings): SQLQueryBindings | undefined {
808
- if (value !== undefined) {
809
- this.db.run(`PRAGMA ${name} = ${value}`)
810
- return undefined
811
- }
812
- const result = this.db.prepare(`PRAGMA ${name}`).get() as Record<
813
- string,
814
- SQLQueryBindings
815
- >
816
- return Object.values(result)[0]
817
- }
818
-
819
- /**
820
- * Load a SQLite extension.
821
- *
822
- * @param path - Absolute path to the compiled SQLite extension
823
- */
824
- loadExtension(path: string): void {
825
- this.db.loadExtension(path)
826
- }
827
-
828
- /**
829
- * Get direct access to the underlying SQLite database instance.
830
- * Use this for advanced operations not covered by the wrapper.
831
- *
832
- * @returns The underlying Database instance
833
- */
834
- getDb(): Database {
835
- return this.db
836
- }
837
- }
838
-
839
- export { DB }
840
- export default DB
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;