@dockstat/sqlite-wrapper 1.3.4 → 1.3.6

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/README.md CHANGED
@@ -9,20 +9,21 @@ Schema-first table helpers, an expressive chainable QueryBuilder, safe defaults
9
9
 
10
10
  ---
11
11
 
12
- ## 🆕 What's New in v1.3
13
-
14
- ### Bug Fixes
15
-
16
- - **Fixed Boolean parsing** — Boolean columns now correctly convert SQLite's `0`/`1` to JavaScript `true`/`false`
17
- - **Fixed Wrong packing** — Before the `publish` script was added, workspace dependencies were not correctly propagated
12
+ ## 🆕 What's New in v1.3.5
18
13
 
19
14
  ### New Features
20
15
 
16
+ - **Automatic Schema Migration** — Tables automatically migrate when schema changes are detected! Add/remove columns, change constraints, and preserve data without manual intervention
21
17
  - **Auto-detection of JSON & Boolean columns** — No more manual parser configuration! Columns using `column.json()` or `column.boolean()` are automatically detected from schema
22
18
  - **Automatic backups with retention** — Configure `autoBackup` to create periodic backups with automatic cleanup of old files
23
19
  - **Backup & Restore API** — New `backup()`, `restore()`, and `listBackups()` methods
24
20
  - **`getPath()` method** — Get the database file path
25
21
 
22
+ ### Bug Fixes
23
+
24
+ - **Fixed Boolean parsing** — Boolean columns now correctly convert SQLite's `0`/`1` to JavaScript `true`/`false`
25
+ - **Fixed incorrect packaging** — Before the `publish` script was added, workspace dependencies were not correctly propagated
26
+
26
27
  ### Architecture Improvements
27
28
 
28
29
  - **New `utils/` module** — Reusable utilities for SQL building, logging, and row transformation
@@ -32,7 +33,7 @@ Schema-first table helpers, an expressive chainable QueryBuilder, safe defaults
32
33
 
33
34
  ### Breaking Changes
34
35
 
35
- - None! v1.3 is fully backward compatible with v1.2.x
36
+ - None! v1.4 is fully backward compatible with v1.3.x
36
37
 
37
38
  ---
38
39
 
@@ -100,6 +101,7 @@ const users = userTable
100
101
  - 🚀 Designed for production workflows: WAL, pragmatic PRAGMAs, bulk ops, transactions
101
102
  - 🔄 **Automatic JSON/Boolean detection** — no manual parser configuration needed
102
103
  - 💾 **Built-in backup & restore** — with automatic retention policies
104
+ - 🔀 **Automatic Schema Migration** — seamlessly migrate tables when schemas change
103
105
 
104
106
  ---
105
107
 
@@ -221,6 +223,100 @@ const db = new DB("app.db", {
221
223
 
222
224
  ---
223
225
 
226
+ ## Automatic Schema Migration
227
+
228
+ When you call `createTable()` with a schema that differs from an existing table, the wrapper automatically:
229
+
230
+ 1. **Detects schema changes** — Compares existing columns with your new definition
231
+ 2. **Migrates the table** — Creates a temporary table, copies data, and swaps tables
232
+ 3. **Preserves data** — Maps columns by name, uses defaults for new columns
233
+ 4. **Maintains indexes & triggers** — Recreates them after migration
234
+
235
+ ### Basic Migration Example
236
+
237
+ ```typescript
238
+ // Initial table creation
239
+ const users = db.createTable("users", {
240
+ id: column.id(),
241
+ name: column.text({ notNull: true }),
242
+ });
243
+
244
+ users.insert({ name: "Alice" });
245
+
246
+ // Later: Add email column (automatic migration!)
247
+ const updatedUsers = db.createTable("users", {
248
+ id: column.id(),
249
+ name: column.text({ notNull: true }),
250
+ email: column.text({ unique: true }), // New column
251
+ });
252
+
253
+ // Data is preserved, new column uses NULL or default
254
+ const user = updatedUsers.where({ name: "Alice" }).first();
255
+ console.log(user); // { id: 1, name: "Alice", email: null }
256
+ ```
257
+
258
+ ### Migration Options
259
+
260
+ Control migration behavior with the `migrate` option:
261
+
262
+ ```typescript
263
+ db.createTable("users", schema, {
264
+ migrate: true, // Default: enable migration
265
+ // OR provide detailed options:
266
+ migrate: {
267
+ preserveData: true, // Copy existing data (default: true)
268
+ dropMissingColumns: true, // Remove columns not in new schema (default: true)
269
+ onConflict: "fail", // How to handle constraint violations: "fail" | "ignore" | "replace"
270
+ tempTableSuffix: "_temp", // Suffix for temporary table during migration
271
+ },
272
+ });
273
+ ```
274
+
275
+ ### Disable Migration
276
+
277
+ For cases where you want to ensure a table matches an exact schema without migration:
278
+
279
+ ```typescript
280
+ db.createTable("users", schema, {
281
+ migrate: false, // Disable migration
282
+ ifNotExists: true, // Avoid errors if table exists
283
+ });
284
+ ```
285
+
286
+ ### Migration Features
287
+
288
+ - **Automatic column mapping** — Columns with matching names are preserved
289
+ - **Type conversions** — Best-effort conversion between compatible types
290
+ - **Constraint handling** — Adds/removes constraints as needed
291
+ - **Index preservation** — Indexes are recreated after migration
292
+ - **Trigger preservation** — Triggers are recreated after migration
293
+ - **Foreign key support** — Handles foreign key constraints properly
294
+ - **Transaction safety** — Migration runs in a transaction, rolls back on error
295
+
296
+ ### When Migration Occurs
297
+
298
+ Migration is triggered when `createTable()` detects:
299
+
300
+ - **Column additions** — New columns in schema
301
+ - **Column removals** — Missing columns from schema
302
+ - **Constraint changes** — Different NOT NULL, UNIQUE, etc.
303
+ - **Type changes** — Different column types
304
+
305
+ ### Migration Limitations
306
+
307
+ - **Data loss possible** — Removing columns or adding NOT NULL without defaults
308
+ - **Type incompatibility** — Some type conversions may fail
309
+ - **Performance** — Large tables take time to migrate
310
+ - **Downtime** — Table is briefly locked during migration
311
+
312
+ For production systems with large tables, consider:
313
+
314
+ - Running migrations during maintenance windows
315
+ - Testing migrations on a copy first
316
+ - Using `migrate: false` and handling manually for critical tables
317
+
318
+ ---
319
+
224
320
  ## Manual Backup & Restore
225
321
 
226
322
  ### Creating Backups
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dockstat/sqlite-wrapper",
3
- "version": "1.3.4",
3
+ "version": "1.3.6",
4
4
  "description": "A TypeScript wrapper around bun:sqlite with type-safe query building",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
package/src/index.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { Database, type SQLQueryBindings } from "bun:sqlite"
2
2
  import { Logger } from "@dockstat/logger"
3
- import type { ColumnDefinition, IndexColumn, IndexMethod, Parser, TableOptions } from "./types"
4
3
  import { backup as helperBackup } from "./lib/backup/backup"
4
+ import { listBackups as helperListBackups } from "./lib/backup/listBackups"
5
+ import { restore as helperRestore } from "./lib/backup/restore"
5
6
  import { setupAutoBackup as helperSetupAutoBackup } from "./lib/backup/setupAutoBackup"
6
-
7
7
  // helpers
8
8
  import { createIndex as helperCreateIndex } from "./lib/index/createIndex"
9
9
  import { dropIndex as helperDropIndex } from "./lib/index/dropIndex"
@@ -12,10 +12,17 @@ import { buildTableConstraints } from "./lib/table/buildTableConstraint"
12
12
  import { getTableComment as helperGetTableComment } from "./lib/table/getTableComment"
13
13
  import { isTableSchema } from "./lib/table/isTableSchema"
14
14
  import { setTableComment as helperSetTableComment } from "./lib/table/setTableComment"
15
+ import { checkAndMigrate, tableExists } from "./migration"
15
16
  import { QueryBuilder } from "./query-builder/index"
17
+ import type {
18
+ ColumnDefinition,
19
+ IndexColumn,
20
+ IndexMethod,
21
+ MigrationOptions,
22
+ Parser,
23
+ TableOptions,
24
+ } from "./types"
16
25
  import { createLogger, type SqliteLogger } from "./utils"
17
- import { listBackups as helperListBackups } from "./lib/backup/listBackups"
18
- import { restore as helperRestore } from "./lib/backup/restore"
19
26
 
20
27
  // Re-export all types and utilities
21
28
  export { QueryBuilder }
@@ -29,6 +36,7 @@ export type {
29
36
  ForeignKeyAction,
30
37
  InsertOptions,
31
38
  InsertResult,
39
+ MigrationOptions,
32
40
  RegexCondition,
33
41
  SQLiteType,
34
42
  TableConstraints,
@@ -87,6 +95,7 @@ class DB {
87
95
  private dbLog: SqliteLogger
88
96
  private backupLog: SqliteLogger
89
97
  private tableLog: SqliteLogger
98
+ private migrationLog: SqliteLogger
90
99
 
91
100
  /**
92
101
  * Open or create a SQLite database at `path`.
@@ -105,6 +114,7 @@ class DB {
105
114
  this.dbLog = createLogger("DB", this.baseLogger)
106
115
  this.backupLog = createLogger("Backup", this.baseLogger)
107
116
  this.tableLog = createLogger("Table", this.baseLogger)
117
+ this.migrationLog = createLogger("Migration", this.baseLogger)
108
118
 
109
119
  this.dbLog.connection(path, "open")
110
120
 
@@ -210,6 +220,23 @@ class DB {
210
220
  }
211
221
  }
212
222
 
223
+ private normalizeMigrationOptions(migrate: TableOptions<Record<string, unknown>>["migrate"]): {
224
+ enabled: boolean
225
+ options: MigrationOptions
226
+ } {
227
+ if (migrate === false) return { enabled: false, options: {} }
228
+ if (migrate && typeof migrate === "object") {
229
+ return { enabled: true, options: migrate }
230
+ }
231
+ return { enabled: true, options: {} }
232
+ }
233
+
234
+ private shouldCheckExistingTable<_T>(tableName: string, options?: TableOptions<_T>): boolean {
235
+ if (options?.temporary) return false
236
+ if (tableName === ":memory:") return false
237
+ return true
238
+ }
239
+
213
240
  /**
214
241
  * Create a table with comprehensive type safety and feature support.
215
242
  *
@@ -224,7 +251,40 @@ class DB {
224
251
  columns: Record<keyof _T, ColumnDefinition>,
225
252
  options?: TableOptions<_T>
226
253
  ): QueryBuilder<_T> {
227
- const temp = options?.temporary ? "TEMPORARY " : tableName === ":memory" ? "TEMPORARY " : ""
254
+ const { enabled: migrateEnabled, options: migrationOptions } = this.normalizeMigrationOptions(
255
+ options?.migrate
256
+ )
257
+
258
+ const tableAlreadyExists =
259
+ this.shouldCheckExistingTable(tableName, options) && tableExists(this.db, tableName)
260
+
261
+ if (tableAlreadyExists) {
262
+ if (migrateEnabled) {
263
+ let tableConstraints: string[] = []
264
+ if (isTableSchema(columns) && options?.constraints) {
265
+ tableConstraints = buildTableConstraints(options.constraints)
266
+ }
267
+
268
+ const migrated = checkAndMigrate(
269
+ this.db,
270
+ tableName,
271
+ columns as Record<string, ColumnDefinition>,
272
+ this.migrationLog,
273
+ migrationOptions,
274
+ tableConstraints
275
+ )
276
+
277
+ if (migrated) {
278
+ return this._setupTableParser(tableName, columns, options)
279
+ }
280
+ }
281
+
282
+ if (!options?.ifNotExists) {
283
+ return this._setupTableParser(tableName, columns, options)
284
+ }
285
+ }
286
+
287
+ const temp = options?.temporary ? "TEMPORARY " : tableName === ":memory:" ? "TEMPORARY " : ""
228
288
  const ifNot = options?.ifNotExists ? "IF NOT EXISTS " : ""
229
289
  const withoutRowId = options?.withoutRowId ? " WITHOUT ROWID" : ""
230
290
 
@@ -288,6 +348,17 @@ class DB {
288
348
  helperSetTableComment(this.db, this.tableLog, tableName, options.comment)
289
349
  }
290
350
 
351
+ return this._setupTableParser(tableName, columns, options)
352
+ }
353
+
354
+ /**
355
+ * Setup parser for table (extracted for reuse after migration)
356
+ */
357
+ private _setupTableParser<_T extends Record<string, unknown> = Record<string, unknown>>(
358
+ tableName: string,
359
+ columns: Record<keyof _T, ColumnDefinition>,
360
+ options?: TableOptions<_T>
361
+ ): QueryBuilder<_T> {
291
362
  // Auto-detect JSON and BOOLEAN columns from schema
292
363
  const autoDetectedJson: Array<keyof _T> = []
293
364
  const autoDetectedBoolean: Array<keyof _T> = []
@@ -0,0 +1,349 @@
1
+ import type { Database } from "bun:sqlite"
2
+ import { buildColumnSQL } from "./lib/table/buildColumnSQL"
3
+ import type {
4
+ ColumnDefinition,
5
+ ForeignKeyInfo,
6
+ ForeignKeyStatus,
7
+ IndexInfo,
8
+ MigrationOptions,
9
+ TableColumn,
10
+ } from "./types"
11
+ import { SqliteLogger } from "./utils/logger"
12
+
13
+ /**
14
+ * Get the current schema of a table
15
+ */
16
+ export function getTableColumns(db: Database, tableName: string): TableColumn[] {
17
+ const stmt = db.prepare(`PRAGMA table_info("${tableName}")`)
18
+ return stmt.all() as TableColumn[]
19
+ }
20
+
21
+ /**
22
+ * Get indexes for a table
23
+ */
24
+ export function getTableIndexes(db: Database, tableName: string): IndexInfo[] {
25
+ interface PragmaIndex {
26
+ seq: number
27
+ name: string
28
+ unique: number
29
+ origin: string
30
+ partial: number
31
+ }
32
+
33
+ interface SqliteMasterRow {
34
+ sql: string | null
35
+ }
36
+
37
+ const stmt = db.prepare(`PRAGMA index_list("${tableName}")`)
38
+ const indexes = stmt.all() as PragmaIndex[]
39
+
40
+ return indexes.map((idx) => {
41
+ const sqlStmt = db.prepare(`SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?`)
42
+ const sqlResult = sqlStmt.get(idx.name) as SqliteMasterRow | null
43
+
44
+ return {
45
+ name: idx.name,
46
+ sql: sqlResult?.sql || null,
47
+ unique: idx.unique === 1,
48
+ origin: idx.origin,
49
+ partial: idx.partial,
50
+ }
51
+ })
52
+ }
53
+
54
+ /**
55
+ * Get foreign keys for a table
56
+ */
57
+ export function getTableForeignKeys(db: Database, tableName: string): ForeignKeyInfo[] {
58
+ const stmt = db.prepare(`PRAGMA foreign_key_list("${tableName}")`)
59
+ return stmt.all() as ForeignKeyInfo[]
60
+ }
61
+
62
+ /**
63
+ * Get triggers for a table
64
+ */
65
+ export function getTableTriggers(
66
+ db: Database,
67
+ tableName: string
68
+ ): Array<{ name: string; sql: string }> {
69
+ const stmt = db.prepare(
70
+ `SELECT name, sql FROM sqlite_master WHERE type = 'trigger' AND tbl_name = ?`
71
+ )
72
+ return stmt.all(tableName) as Array<{ name: string; sql: string }>
73
+ }
74
+
75
+ /**
76
+ * Check if a table exists
77
+ */
78
+ export function tableExists(db: Database, tableName: string): boolean {
79
+ const stmt = db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
80
+ const result = stmt.get(tableName)
81
+ return result !== null
82
+ }
83
+
84
+ /**
85
+ * Compare two schemas to determine if migration is needed
86
+ */
87
+ export function schemasAreDifferent(
88
+ currentSchema: TableColumn[],
89
+ newColumns: Record<string, ColumnDefinition>,
90
+ logger: SqliteLogger
91
+ ): boolean {
92
+ logger.info("Comparing schemas")
93
+ const currentColumnNames = new Set(currentSchema.map((col) => col.name))
94
+ const newColumnNames = new Set(Object.keys(newColumns))
95
+
96
+ // Check if column count differs
97
+ if (currentColumnNames.size !== newColumnNames.size) {
98
+ logger.info(
99
+ `Column count differs (current: ${currentColumnNames.size} [${Array.from(currentColumnNames).join(", ")}], new: ${newColumnNames.size} [${Array.from(newColumnNames).join(", ")}])`
100
+ )
101
+ return true
102
+ }
103
+
104
+ // Check if all column names match
105
+ for (const name of newColumnNames) {
106
+ if (!currentColumnNames.has(name)) {
107
+ logger.info(`Column ${name} does not exist`)
108
+ return true
109
+ }
110
+ logger.debug(`Column ${name} exists`)
111
+ }
112
+
113
+ // Check column definitions
114
+ for (const currentCol of currentSchema) {
115
+ const newCol = newColumns[currentCol.name]
116
+ if (!newCol) {
117
+ logger.info(`Column ${currentCol.name} does not exist`)
118
+ return true
119
+ }
120
+
121
+ // For primary key columns, SQLite auto-adds AUTOINCREMENT which we need to account for
122
+ const isCurrentPK = currentCol.pk === 1
123
+ const isNewPK = newCol.primaryKey === true || newCol.autoincrement === true
124
+
125
+ // If both are primary keys, consider them the same (don't check other constraints)
126
+ if (isCurrentPK && isNewPK) {
127
+ logger.info(`Column ${currentCol.name} is a primary key`)
128
+ continue
129
+ }
130
+
131
+ if (isCurrentPK !== isNewPK) {
132
+ logger.info(
133
+ `Column ${currentCol.name} primary key status differs (current: ${isCurrentPK}, new: ${isNewPK})`
134
+ )
135
+ return true
136
+ }
137
+
138
+ // Check if NOT NULL constraint differs (ignore for primary keys as they're always NOT NULL)
139
+ if (!isCurrentPK) {
140
+ const isCurrentNotNull = currentCol.notnull === 1
141
+ const isNewNotNull = newCol.notNull === true
142
+ if (isCurrentNotNull !== isNewNotNull) {
143
+ logger.info(
144
+ `Column ${currentCol.name} NOT NULL constraint differs (current: ${isCurrentNotNull}, new: ${isNewNotNull})`
145
+ )
146
+ return true
147
+ }
148
+ }
149
+
150
+ // Basic type comparison (normalize types)
151
+ const normalizeType = (type: string) => type.toUpperCase().split("(")[0].trim()
152
+ const currentType = normalizeType(currentCol.type)
153
+ const newType = normalizeType(newCol.type)
154
+
155
+ // Handle type aliases
156
+ const typeAliases: Record<string, string> = {
157
+ INT: "INTEGER",
158
+ BOOL: "INTEGER",
159
+ BOOLEAN: "INTEGER",
160
+ JSON: "TEXT",
161
+ VARCHAR: "TEXT",
162
+ CHAR: "TEXT",
163
+ DATETIME: "TEXT",
164
+ DATE: "TEXT",
165
+ TIMESTAMP: "TEXT",
166
+ }
167
+
168
+ const normalizedCurrentType = typeAliases[currentType] || currentType
169
+ const normalizedNewType = typeAliases[newType] || newType
170
+
171
+ if (normalizedCurrentType !== normalizedNewType) {
172
+ logger.info(`Column ${currentCol.name} type differs`)
173
+ return true
174
+ }
175
+ }
176
+
177
+ logger.info("No schema changes detected")
178
+ return false
179
+ }
180
+
181
+ /**
182
+ * Generate column mapping for data migration
183
+ */
184
+ export function generateColumnMapping(
185
+ currentSchema: TableColumn[],
186
+ newColumns: Record<string, ColumnDefinition>
187
+ ): { selectColumns: string[]; insertColumns: string[] } {
188
+ const currentColumnNames = new Set(currentSchema.map((col) => col.name))
189
+ const newColumnNames = Object.keys(newColumns)
190
+
191
+ // Find common columns
192
+ const commonColumns = newColumnNames.filter((name) => currentColumnNames.has(name))
193
+
194
+ return {
195
+ selectColumns: commonColumns,
196
+ insertColumns: commonColumns,
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Migrate a table to a new schema
202
+ */
203
+ export function migrateTable(
204
+ db: Database,
205
+ tableName: string,
206
+ newColumns: Record<string, ColumnDefinition>,
207
+ options: MigrationOptions = {},
208
+ tableConstraints: string[] = [],
209
+ migrationLog: SqliteLogger,
210
+ currentSchema?: TableColumn[]
211
+ ): void {
212
+ const { preserveData = true, onConflict = "fail", tempTableSuffix = "_migration_temp" } = options
213
+
214
+ migrationLog.info(`Starting migration for table: ${tableName}`)
215
+
216
+ // Get current table info
217
+ const effectiveSchema = currentSchema ?? getTableColumns(db, tableName)
218
+ const indexes = getTableIndexes(db, tableName)
219
+ const triggers = getTableTriggers(db, tableName)
220
+
221
+ // Check if migration is needed
222
+ if (!schemasAreDifferent(effectiveSchema, newColumns, migrationLog)) {
223
+ migrationLog.info(`No migration needed for table: ${tableName}`)
224
+ return
225
+ }
226
+
227
+ const tempTableName = `${tableName}${tempTableSuffix}`
228
+
229
+ const fkStatus = db.prepare("PRAGMA foreign_keys").get() as ForeignKeyStatus | null
230
+ if (fkStatus && fkStatus.foreign_keys === 1) {
231
+ db.run("PRAGMA foreign_keys = OFF")
232
+ }
233
+
234
+ db.transaction(() => {
235
+ try {
236
+ // Step 1: Create temporary table with new schema
237
+ migrationLog.debug(`Creating temporary table: ${tempTableName}`)
238
+ const columnDefs: string[] = []
239
+
240
+ for (const [colName, colDef] of Object.entries(newColumns)) {
241
+ const sqlDef = buildColumnSQL(colName, colDef)
242
+ columnDefs.push(`"${colName}" ${sqlDef}`)
243
+ }
244
+
245
+ // Include table constraints if provided
246
+ const allDefinitions =
247
+ tableConstraints.length > 0
248
+ ? [...columnDefs, ...tableConstraints].join(", ")
249
+ : columnDefs.join(", ")
250
+
251
+ const createTempTableSql = `CREATE TABLE "${tempTableName}" (${allDefinitions})`
252
+ db.run(createTempTableSql)
253
+
254
+ // Step 2: Copy data if requested
255
+ if (preserveData && effectiveSchema.length > 0) {
256
+ const { selectColumns, insertColumns } = generateColumnMapping(effectiveSchema, newColumns)
257
+
258
+ if (selectColumns.length > 0) {
259
+ migrationLog.debug(`Copying data from ${tableName} to ${tempTableName}`)
260
+
261
+ const quotedSelectCols = selectColumns.map((col) => `"${col}"`).join(", ")
262
+ const quotedInsertCols = insertColumns.map((col) => `"${col}"`).join(", ")
263
+
264
+ let copySql = `INSERT INTO "${tempTableName}" (${quotedInsertCols})
265
+ SELECT ${quotedSelectCols} FROM "${tableName}"`
266
+
267
+ if (onConflict === "ignore") {
268
+ copySql = `INSERT OR IGNORE INTO "${tempTableName}" (${quotedInsertCols})
269
+ SELECT ${quotedSelectCols} FROM "${tableName}"`
270
+ } else if (onConflict === "replace") {
271
+ copySql = `INSERT OR REPLACE INTO "${tempTableName}" (${quotedInsertCols})
272
+ SELECT ${quotedSelectCols} FROM "${tableName}"`
273
+ }
274
+
275
+ db.run(copySql)
276
+ }
277
+ }
278
+
279
+ // Step 3: Drop the original table
280
+ migrationLog.debug(`Dropping original table: ${tableName}`)
281
+
282
+ db.run(`DROP TABLE "${tableName}"`)
283
+
284
+ // Step 4: Rename temporary table to original name
285
+ migrationLog.debug(`Renaming ${tempTableName} to ${tableName}`)
286
+ db.run(`ALTER TABLE "${tempTableName}" RENAME TO "${tableName}"`)
287
+
288
+ // Step 5: Recreate indexes
289
+ for (const index of indexes) {
290
+ if (index.sql && !index.sql.includes("sqlite_autoindex")) {
291
+ migrationLog.debug(`Recreating index: ${index.name}`)
292
+ try {
293
+ db.run(index.sql)
294
+ } catch (err) {
295
+ migrationLog.warn(`Failed to recreate index ${index.name}: ${err}`)
296
+ }
297
+ }
298
+ }
299
+
300
+ // Step 6: Recreate triggers
301
+ for (const trigger of triggers) {
302
+ migrationLog.debug(`Recreating trigger: ${trigger.name}`)
303
+ try {
304
+ db.run(trigger.sql)
305
+ } catch (err) {
306
+ migrationLog.warn(`Failed to recreate trigger ${trigger.name}: ${err}`)
307
+ }
308
+ }
309
+
310
+ // Re-enable foreign key constraints if they were enabled
311
+
312
+ migrationLog.info(`Successfully migrated table: ${tableName}`)
313
+ } catch (error) {
314
+ migrationLog.error(`Migration failed for table ${tableName}: ${error}`)
315
+ throw error
316
+ }
317
+ })()
318
+
319
+ if (fkStatus && fkStatus.foreign_keys === 1) {
320
+ db.run("PRAGMA foreign_keys = ON")
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Check if migration is needed and perform if necessary
326
+ */
327
+ export function checkAndMigrate(
328
+ db: Database,
329
+ tableName: string,
330
+ newColumns: Record<string, ColumnDefinition>,
331
+ migrationLog: SqliteLogger,
332
+ options?: MigrationOptions,
333
+ tableConstraints: string[] = []
334
+ ): boolean {
335
+ const logger = new SqliteLogger(tableName, migrationLog.getBaseLogger())
336
+
337
+ if (!tableExists(db, tableName)) {
338
+ return false
339
+ }
340
+
341
+ const currentSchema = getTableColumns(db, tableName)
342
+
343
+ if (schemasAreDifferent(currentSchema, newColumns, logger)) {
344
+ migrateTable(db, tableName, newColumns, options, tableConstraints, migrationLog, currentSchema)
345
+ return true
346
+ }
347
+
348
+ return false
349
+ }
@@ -175,6 +175,31 @@ export class SelectQueryBuilder<T extends Record<string, unknown>> extends Where
175
175
  return result
176
176
  }
177
177
 
178
+ private logSelectStart(
179
+ method: string,
180
+ details: { query?: string; optimizedQuery?: string; params?: unknown }
181
+ ): void {
182
+ const { query, optimizedQuery, params } = details
183
+ const q = query ?? optimizedQuery ?? ""
184
+ this.selectLog.info(
185
+ `${method} | ${query ? "query" : "optimizedQuery"}=${q} params=${WhereQueryBuilder.safeStringify(params)}`
186
+ )
187
+ }
188
+
189
+ private logSelectReturn(
190
+ method: string,
191
+ details: { returned?: unknown; length?: number; sample?: unknown }
192
+ ): void {
193
+ const { returned, length, sample } = details
194
+ if (length !== undefined && sample !== undefined) {
195
+ this.selectLog.info(
196
+ `${method} | returned=${length} sample=${WhereQueryBuilder.safeStringify(sample)}`
197
+ )
198
+ } else {
199
+ this.selectLog.info(`${method} | returned=${WhereQueryBuilder.safeStringify(returned)}`)
200
+ }
201
+ }
202
+
178
203
  // ===== Execution Methods =====
179
204
 
180
205
  // Use the protected static helper inherited from WhereQueryBuilder: `safeStringify`
@@ -190,27 +215,21 @@ export class SelectQueryBuilder<T extends Record<string, unknown>> extends Where
190
215
  const hasRegex = this.hasRegexConditions()
191
216
  const [query, params] = this.buildSelectQuery(!hasRegex)
192
217
 
193
- // Info-level log for invocation
194
- this.selectLog.info(`all | query=${query} params=${WhereQueryBuilder.safeStringify(params)}`)
195
-
218
+ this.logSelectStart("all", { query, params })
196
219
  this.selectLog.query("SELECT", query, params)
197
220
 
198
221
  const rows = this.getDb()
199
222
  .prepare(query)
200
223
  .all(...params) as T[]
201
-
202
224
  this.selectLog.result("SELECT", rows.length)
203
225
 
204
- // Transform rows (JSON/Boolean parsing)
205
226
  const transformed = this.transformRowsFromDb(rows)
206
-
207
- // Apply client-side operations if needed
208
227
  const result = hasRegex ? this.applyClientSideOperations(transformed) : transformed
209
228
 
210
- // Info-level log for returned data (length + sample)
211
- this.selectLog.info(
212
- `all | returned=${result.length} sample=${WhereQueryBuilder.safeStringify(result[0] ?? null)}`
213
- )
229
+ this.logSelectReturn("all", {
230
+ length: result.length,
231
+ sample: result[0] ?? null,
232
+ })
214
233
 
215
234
  this.reset()
216
235
  return result
@@ -222,58 +241,43 @@ export class SelectQueryBuilder<T extends Record<string, unknown>> extends Where
222
241
  * Respects LIMIT if set, otherwise adds LIMIT 1 for efficiency
223
242
  */
224
243
  get(): T | null {
225
- // If no regex and no explicit limit, optimize with LIMIT 1
226
244
  if (!this.hasRegexConditions() && this.limitValue === undefined) {
227
245
  const [query, params] = this.buildSelectQuery(true)
228
246
  const optimizedQuery = `${query} LIMIT 1`
229
247
 
230
- // Info-level log for invocation
231
- this.selectLog.info(
232
- `get | optimizedQuery=${optimizedQuery} params=${WhereQueryBuilder.safeStringify(params)}`
233
- )
234
-
248
+ this.logSelectStart("get", { optimizedQuery, params })
235
249
  this.selectLog.query("SELECT (get)", optimizedQuery, params)
236
250
 
237
251
  const row = this.getDb()
238
252
  .prepare(optimizedQuery)
239
253
  .get(...params) as T | null
240
-
241
254
  this.selectLog.result("SELECT (get)", row ? 1 : 0)
242
255
 
243
256
  const result = row ? this.transformRowFromDb(row) : null
244
-
245
- // Info-level log for returned row
246
- this.selectLog.info(`get | returned=${WhereQueryBuilder.safeStringify(result)}`)
257
+ this.logSelectReturn("get", { returned: result })
247
258
 
248
259
  this.reset()
249
260
  return result
250
261
  }
251
262
 
252
- // If limit is set or regex conditions exist, use standard flow
253
263
  if (!this.hasRegexConditions()) {
254
264
  const [query, params] = this.buildSelectQuery(true)
255
265
 
256
- // Info-level log for invocation
257
- this.selectLog.info(`get | query=${query} params=${WhereQueryBuilder.safeStringify(params)}`)
258
-
266
+ this.logSelectStart("get", { query, params })
259
267
  this.selectLog.query("SELECT (get)", query, params)
260
268
 
261
269
  const row = this.getDb()
262
270
  .prepare(query)
263
271
  .get(...params) as T | null
264
-
265
272
  this.selectLog.result("SELECT (get)", row ? 1 : 0)
266
273
 
267
274
  const result = row ? this.transformRowFromDb(row) : null
268
-
269
- // Info-level log for returned row
270
- this.selectLog.info(`get | returned=${WhereQueryBuilder.safeStringify(result)}`)
275
+ this.logSelectReturn("get", { returned: result })
271
276
 
272
277
  this.reset()
273
278
  return result
274
279
  }
275
280
 
276
- // Has regex conditions - fall back to all() and take first
277
281
  const results = this.all()
278
282
  return results[0] ?? null
279
283
  }
@@ -295,30 +299,23 @@ export class SelectQueryBuilder<T extends Record<string, unknown>> extends Where
295
299
  */
296
300
  count(): number {
297
301
  if (!this.hasRegexConditions()) {
298
- // Use SQL COUNT for efficiency
299
302
  const [whereClause, whereParams] = this.buildWhereClause()
300
303
  const query = `SELECT COUNT(*) AS __count FROM ${quoteIdentifier(this.getTableName())}${whereClause}`
301
304
 
302
- // Info-level log
303
- this.selectLog.info(
304
- `count | query=${query} params=${WhereQueryBuilder.safeStringify(whereParams)}`
305
- )
306
-
305
+ this.logSelectStart("count", { query, params: whereParams })
307
306
  this.selectLog.query("COUNT", query, whereParams)
308
307
 
309
308
  const result = this.getDb()
310
309
  .prepare(query)
311
310
  .get(...whereParams) as { __count: number } | null
312
311
 
312
+ const count = result?.__count ?? 0
313
313
  this.reset()
314
314
 
315
- // Info-level log for returned count
316
- this.selectLog.info(`count | returned=${result?.__count ?? 0}`)
317
-
318
- return result?.__count ?? 0
315
+ this.logSelectReturn("count", { returned: count })
316
+ return count
319
317
  }
320
318
 
321
- // Has regex conditions - count client-side
322
319
  const results = this.all()
323
320
  return results.length
324
321
  }
@@ -328,50 +325,31 @@ export class SelectQueryBuilder<T extends Record<string, unknown>> extends Where
328
325
  */
329
326
  exists(): boolean {
330
327
  if (!this.hasRegexConditions()) {
331
- // Use EXISTS for efficiency
332
328
  const [whereClause, whereParams] = this.buildWhereClause()
333
329
  const subquery = `SELECT 1 FROM ${quoteIdentifier(this.getTableName())}${whereClause} LIMIT 1`
334
330
  const query = `SELECT EXISTS(${subquery}) AS __exists`
335
331
 
336
- // Info-level log
337
- this.selectLog.info(
338
- `exists | query=${query} params=${WhereQueryBuilder.safeStringify(whereParams)}`
339
- )
340
-
332
+ this.logSelectStart("exists", { query, params: whereParams })
341
333
  this.selectLog.query("EXISTS", query, whereParams)
342
334
 
343
335
  const result = this.getDb()
344
336
  .prepare(query)
345
337
  .get(...whereParams) as { __exists: number } | null
346
338
 
339
+ const exists = Boolean(result?.__exists)
347
340
  this.reset()
348
341
 
349
- // Info-level log for returned boolean
350
- this.selectLog.info(`exists | returned=${Boolean(result?.__exists)}`)
351
-
352
- return Boolean(result?.__exists)
342
+ this.logSelectReturn("exists", { returned: exists })
343
+ return exists
353
344
  }
354
345
 
355
- // Has regex conditions - check client-side
356
346
  return this.count() > 0
357
347
  }
358
348
 
359
- /**
360
- * Get a single column value from the first matching row
361
- *
362
- * @example
363
- * const name = table.where({ id: 1 }).value("name")
364
- */
365
- value<K extends keyof T>(column: K): T[K] | null {
366
- const row = this.first()
367
- const value = row ? row[column] : null
368
-
369
- // Info-level log
349
+ private logColumnReturn(method: "value" | "pluck", column: string, returned: unknown): void {
370
350
  this.selectLog.info(
371
- `value | column=${String(column)} returned=${WhereQueryBuilder.safeStringify(value)}`
351
+ `${method} | column=${column} returned=${WhereQueryBuilder.safeStringify(returned)}`
372
352
  )
373
-
374
- return value
375
353
  }
376
354
 
377
355
  /**
@@ -381,14 +359,24 @@ export class SelectQueryBuilder<T extends Record<string, unknown>> extends Where
381
359
  * const emails = table.where({ active: true }).pluck("email")
382
360
  */
383
361
  pluck<K extends keyof T>(column: K): T[K][] {
384
- const rows = this.all()
362
+ const rows = this.all() || []
385
363
  const values = rows.map((row) => row[column])
386
364
 
387
- // Info-level log
388
- this.selectLog.info(
389
- `pluck | column=${String(column)} returned=${WhereQueryBuilder.safeStringify(values)}`
390
- )
391
-
365
+ this.logColumnReturn("pluck", String(column), values)
392
366
  return values
393
367
  }
368
+
369
+ /**
370
+ * Get a single column value from the first matching row
371
+ *
372
+ * @example
373
+ * const name = table.where({ id: 1 }).value("name")
374
+ */
375
+ value<K extends keyof T>(column: K): T[K] | null {
376
+ const row = this.first()
377
+ const value = row ? row[column] : null
378
+
379
+ this.logColumnReturn("value", String(column), value)
380
+ return value
381
+ }
394
382
  }
@@ -1,6 +1,12 @@
1
1
  import type { SQLQueryBindings } from "bun:sqlite"
2
2
  import type { RegexCondition, WhereCondition } from "../types"
3
- import { buildBetweenClause, buildInClause, normalizeOperator, quoteIdentifier } from "../utils"
3
+ import {
4
+ buildBetweenClause,
5
+ buildInClause,
6
+ normalizeOperator,
7
+ quoteIdentifier,
8
+ truncate,
9
+ } from "../utils"
4
10
  import { BaseQueryBuilder } from "./base"
5
11
 
6
12
  /**
@@ -17,6 +23,20 @@ import { BaseQueryBuilder } from "./base"
17
23
  */
18
24
  export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQueryBuilder<T> {
19
25
  // ===== Private Helpers =====
26
+ protected logWhere(method: string, data: Record<string, unknown>): void {
27
+ const parts = Object.entries(data).map(
28
+ ([key, value]) => `${key}=${WhereQueryBuilder.safeStringify(value)}`
29
+ )
30
+ this.log.info(`${method} | ${parts.join(" ")}`)
31
+ }
32
+
33
+ protected logWhereState(method: string, extra: Record<string, unknown> = {}): void {
34
+ this.logWhere(method, {
35
+ whereConditions: this.state.whereConditions,
36
+ whereParams: this.state.whereParams,
37
+ ...extra,
38
+ })
39
+ }
20
40
 
21
41
  /**
22
42
  * Remove an existing condition for a column to prevent duplicates
@@ -57,9 +77,10 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
57
77
  // Helper to stringify values safely (converts RegExp to string and falls back)
58
78
  protected static safeStringify(obj: unknown): string {
59
79
  try {
60
- return JSON.stringify(obj, (_k, v) => (v instanceof RegExp ? v.toString() : v))
80
+ const dat = JSON.stringify(obj, (_k, v) => (v instanceof RegExp ? v.toString() : v))
81
+ return truncate(dat, 100)
61
82
  } catch {
62
- return String(obj)
83
+ return truncate(String(obj), 100)
63
84
  }
64
85
  }
65
86
 
@@ -75,8 +96,7 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
75
96
  * // WHERE "deleted_at" IS NULL
76
97
  */
77
98
  where(conditions: WhereCondition<T>): this {
78
- // Log invocation with a safe serializer (handles RegExp and other non-JSON types)
79
- this.log.info(`where | conditions=${WhereQueryBuilder.safeStringify(conditions)}`)
99
+ this.logWhere("where", { conditions })
80
100
 
81
101
  for (const [column, value] of Object.entries(conditions)) {
82
102
  this.removeExistingCondition(column)
@@ -89,13 +109,7 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
89
109
  }
90
110
  }
91
111
 
92
- // Log resulting WHERE clause state
93
- this.log.info(
94
- `where | whereConditions=${WhereQueryBuilder.safeStringify(this.state.whereConditions)} params=${WhereQueryBuilder.safeStringify(
95
- this.state.whereParams
96
- )}`
97
- )
98
-
112
+ this.logWhereState("where")
99
113
  return this
100
114
  }
101
115
 
@@ -130,11 +144,7 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
130
144
  }
131
145
 
132
146
  // Log resulting WHERE/regex state
133
- this.log.info(
134
- `whereRgx | whereConditions=${WhereQueryBuilder.safeStringify(this.state.whereConditions)} regexConditions=${WhereQueryBuilder.safeStringify(
135
- this.state.regexConditions
136
- )} params=${WhereQueryBuilder.safeStringify(this.state.whereParams)}`
137
- )
147
+ this.logWhereState("whereRgx", { regexConditions: this.state.regexConditions })
138
148
 
139
149
  return this
140
150
  }
@@ -256,25 +266,20 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
256
266
  * .whereOp("name", "LIKE", "%smith%")
257
267
  */
258
268
  whereOp(column: keyof T, op: string, value: SQLQueryBindings): this {
259
- // Log invocation
260
- this.log.info(
261
- `whereOp | column=${String(column)} op=${op} value=${WhereQueryBuilder.safeStringify(value)}`
262
- )
269
+ const columnStr = String(column)
270
+ this.logWhere("whereOp", { column: columnStr, op, value })
263
271
 
264
272
  const normalizedOp = normalizeOperator(op)
265
- const columnStr = String(column)
266
273
 
267
- // Handle NULL special cases
268
274
  if (value === null || value === undefined) {
269
275
  if (normalizedOp === "=" || normalizedOp === "IS") {
270
276
  this.state.whereConditions.push(`${quoteIdentifier(columnStr)} IS NULL`)
271
- // Log resulting state for null-case
272
- this.log.info(`whereOp | added IS NULL for ${columnStr}`)
277
+ this.logWhere("whereOp", { column: columnStr, added: "IS NULL" })
273
278
  return this
274
279
  }
275
280
  if (normalizedOp === "!=" || normalizedOp === "<>" || normalizedOp === "IS NOT") {
276
281
  this.state.whereConditions.push(`${quoteIdentifier(columnStr)} IS NOT NULL`)
277
- this.log.info(`whereOp | added IS NOT NULL for ${columnStr}`)
282
+ this.logWhere("whereOp", { column: columnStr, added: "IS NOT NULL" })
278
283
  return this
279
284
  }
280
285
  }
@@ -282,13 +287,7 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
282
287
  this.state.whereConditions.push(`${quoteIdentifier(columnStr)} ${normalizedOp} ?`)
283
288
  this.state.whereParams.push(value)
284
289
 
285
- // Log resulting WHERE clause state
286
- this.log.info(
287
- `whereOp | whereConditions=${WhereQueryBuilder.safeStringify(this.state.whereConditions)} params=${WhereQueryBuilder.safeStringify(
288
- this.state.whereParams
289
- )}`
290
- )
291
-
290
+ this.logWhereState("whereOp")
292
291
  return this
293
292
  }
294
293
 
package/src/types.ts CHANGED
@@ -237,6 +237,15 @@ export interface TableConstraints<T> {
237
237
  /**
238
238
  * Enhanced table options
239
239
  */
240
+ export interface MigrationOptions {
241
+ /** Whether to preserve data during migration (default: true) */
242
+ preserveData?: boolean
243
+ /** How to handle constraint violations during data copy */
244
+ onConflict?: "fail" | "ignore" | "replace"
245
+ /** Suffix for temporary table during migration (default: '_migration_temp') */
246
+ tempTableSuffix?: string
247
+ }
248
+
240
249
  export interface TableOptions<T> {
241
250
  /** Add IF NOT EXISTS clause */
242
251
  ifNotExists?: boolean
@@ -248,6 +257,8 @@ export interface TableOptions<T> {
248
257
  temporary?: boolean
249
258
  /** Table comment */
250
259
  comment?: string
260
+ /** Enable automatic schema migration (default: true) */
261
+ migrate?: boolean | MigrationOptions
251
262
 
252
263
  parser?: Partial<Parser<T>>
253
264
  }
@@ -461,9 +472,9 @@ export const column = {
461
472
  /**
462
473
  * Create a foreign key reference column
463
474
  */
464
- foreignKey: (
475
+ foreignKey: <_T extends Record<string, unknown>>(
465
476
  refTable: string,
466
- refColumn = "id",
477
+ refColumn: keyof _T = "id",
467
478
  constraints?: ColumnConstraints & {
468
479
  onDelete?: ForeignKeyAction
469
480
  onUpdate?: ForeignKeyAction
@@ -473,7 +484,7 @@ export const column = {
473
484
  type: constraints?.type || SQLiteTypes.INTEGER,
474
485
  references: {
475
486
  table: refTable,
476
- column: refColumn,
487
+ column: String(refColumn),
477
488
  onDelete: constraints?.onDelete,
478
489
  onUpdate: constraints?.onUpdate,
479
490
  },
@@ -617,3 +628,35 @@ export type SqlParameter = SQLQueryBindings
617
628
  * Database row with unknown structure
618
629
  */
619
630
  export type DatabaseRowData = Record<string, SQLQueryBindings>
631
+
632
+ export interface TableColumn {
633
+ cid: number
634
+ name: string
635
+ type: string
636
+ notnull: number
637
+ dflt_value: string | number | null
638
+ pk: number
639
+ }
640
+
641
+ export interface IndexInfo {
642
+ name: string
643
+ sql: string | null
644
+ unique: boolean
645
+ origin: string
646
+ partial: number
647
+ }
648
+
649
+ export interface ForeignKeyInfo {
650
+ id: number
651
+ seq: number
652
+ table: string
653
+ from: string
654
+ to: string
655
+ on_update: string
656
+ on_delete: string
657
+ match: string
658
+ }
659
+
660
+ export interface ForeignKeyStatus {
661
+ foreign_keys: number
662
+ }
@@ -44,3 +44,5 @@ export {
44
44
  transformRowsToDb,
45
45
  transformToDb,
46
46
  } from "./transformer"
47
+
48
+ export * from "./truncate"
@@ -0,0 +1,6 @@
1
+ export function truncate(str: string, maxLength: number, suffix = "..."): string {
2
+ if (str.length <= maxLength) {
3
+ return str
4
+ }
5
+ return str.slice(0, maxLength) + suffix
6
+ }