@dockstat/sqlite-wrapper 1.3.2 → 1.3.3

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