@dockstat/sqlite-wrapper 1.3.3 → 1.3.5

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,26 +9,31 @@ 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
- - **Fixed Boolean parsing** — Boolean columns now correctly convert SQLite's `0`/`1` to JavaScript `true`/`false`
16
- - **Fixed Wrong packing** — Before the `publish` script was added, workspace dependencies were not correctly propagated
12
+ ## 🆕 What's New in v1.3.5
17
13
 
18
14
  ### New Features
15
+
16
+ - **Automatic Schema Migration** — Tables automatically migrate when schema changes are detected! Add/remove columns, change constraints, and preserve data without manual intervention
19
17
  - **Auto-detection of JSON & Boolean columns** — No more manual parser configuration! Columns using `column.json()` or `column.boolean()` are automatically detected from schema
20
18
  - **Automatic backups with retention** — Configure `autoBackup` to create periodic backups with automatic cleanup of old files
21
19
  - **Backup & Restore API** — New `backup()`, `restore()`, and `listBackups()` methods
22
20
  - **`getPath()` method** — Get the database file path
23
21
 
22
+ ### Bug Fixes
23
+
24
+ - **Fixed Boolean parsing** — Boolean columns now correctly convert SQLite's `0`/`1` to JavaScript `true`/`false`
25
+ - **Fixed Wrong packing** — Before the `publish` script was added, workspace dependencies were not correctly propagated
26
+
24
27
  ### Architecture Improvements
28
+
25
29
  - **New `utils/` module** — Reusable utilities for SQL building, logging, and row transformation
26
30
  - **Structured logging** — Cleaner, more consistent log output with dedicated loggers per component
27
31
  - **Reduced code duplication** — Extracted common patterns into shared utilities
28
32
  - **Better maintainability** — Clearer separation of concerns across modules
29
33
 
30
34
  ### Breaking Changes
31
- - None! v1.3 is fully backward compatible with v1.2.x
35
+
36
+ - None! v1.4 is fully backward compatible with v1.3.x
32
37
 
33
38
  ---
34
39
 
@@ -96,6 +101,7 @@ const users = userTable
96
101
  - 🚀 Designed for production workflows: WAL, pragmatic PRAGMAs, bulk ops, transactions
97
102
  - 🔄 **Automatic JSON/Boolean detection** — no manual parser configuration needed
98
103
  - 💾 **Built-in backup & restore** — with automatic retention policies
104
+ - 🔀 **Automatic Schema Migration** — seamlessly migrate tables when schemas change
99
105
 
100
106
  ---
101
107
 
@@ -217,6 +223,100 @@ const db = new DB("app.db", {
217
223
 
218
224
  ---
219
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
+
220
320
  ## Manual Backup & Restore
221
321
 
222
322
  ### Creating Backups
@@ -284,7 +384,10 @@ const activeAdmins = userTable
284
384
  .all();
285
385
 
286
386
  // Get first match
287
- const user = userTable.select(["*"]).where({ email: "alice@example.com" }).first();
387
+ const user = userTable
388
+ .select(["*"])
389
+ .where({ email: "alice@example.com" })
390
+ .first();
288
391
 
289
392
  // Count records
290
393
  const count = userTable.where({ active: true }).count();
@@ -321,7 +424,10 @@ userTable.insertOrIgnore({ email: "existing@example.com", name: "Name" });
321
424
  userTable.insertOrReplace({ email: "existing@example.com", name: "New Name" });
322
425
 
323
426
  // Insert and get the row back
324
- const newUser = userTable.insertAndGet({ name: "Charlie", email: "charlie@example.com" });
427
+ const newUser = userTable.insertAndGet({
428
+ name: "Charlie",
429
+ email: "charlie@example.com",
430
+ });
325
431
  ```
326
432
 
327
433
  ### UPDATE Operations
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dockstat/sqlite-wrapper",
3
- "version": "1.3.3",
3
+ "version": "1.3.5",
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
+ Parser,
22
+ TableOptions,
23
+ MigrationOptions,
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
 
@@ -224,6 +234,49 @@ class DB {
224
234
  columns: Record<keyof _T, ColumnDefinition>,
225
235
  options?: TableOptions<_T>
226
236
  ): QueryBuilder<_T> {
237
+ // Handle migration if enabled (default: true)
238
+ const migrateOption = options?.migrate !== undefined ? options.migrate : true
239
+
240
+ // Check if table already exists
241
+ const tableAlreadyExists =
242
+ !options?.temporary && tableName !== ":memory:" && tableExists(this.db, tableName)
243
+
244
+ if (tableAlreadyExists) {
245
+ if (migrateOption !== false) {
246
+ // Migration is enabled, check if we need to migrate
247
+ const migrationOptions: MigrationOptions =
248
+ typeof migrateOption === "object" ? migrateOption : {}
249
+
250
+ // Build table constraints to pass to migration
251
+ let tableConstraints: string[] = []
252
+ if (isTableSchema(columns) && options?.constraints) {
253
+ tableConstraints = buildTableConstraints(options.constraints)
254
+ }
255
+
256
+ const migrated = checkAndMigrate(
257
+ this.db,
258
+ tableName,
259
+ columns as Record<string, ColumnDefinition>,
260
+ this.migrationLog,
261
+ migrationOptions,
262
+ tableConstraints
263
+ )
264
+
265
+ if (migrated) {
266
+ // Table was migrated, skip creation and go directly to parser setup
267
+ return this._setupTableParser(tableName, columns, options)
268
+ }
269
+ }
270
+
271
+ // Table exists and either:
272
+ // 1. Migration is disabled, or
273
+ // 2. Migration is enabled but no changes were needed
274
+ // In both cases, return the existing table without trying to create it
275
+ if (!options?.ifNotExists) {
276
+ return this._setupTableParser(tableName, columns, options)
277
+ }
278
+ }
279
+
227
280
  const temp = options?.temporary ? "TEMPORARY " : tableName === ":memory" ? "TEMPORARY " : ""
228
281
  const ifNot = options?.ifNotExists ? "IF NOT EXISTS " : ""
229
282
  const withoutRowId = options?.withoutRowId ? " WITHOUT ROWID" : ""
@@ -288,6 +341,17 @@ class DB {
288
341
  helperSetTableComment(this.db, this.tableLog, tableName, options.comment)
289
342
  }
290
343
 
344
+ return this._setupTableParser(tableName, columns, options)
345
+ }
346
+
347
+ /**
348
+ * Setup parser for table (extracted for reuse after migration)
349
+ */
350
+ private _setupTableParser<_T extends Record<string, unknown> = Record<string, unknown>>(
351
+ tableName: string,
352
+ columns: Record<keyof _T, ColumnDefinition>,
353
+ options?: TableOptions<_T>
354
+ ): QueryBuilder<_T> {
291
355
  // Auto-detect JSON and BOOLEAN columns from schema
292
356
  const autoDetectedJson: Array<keyof _T> = []
293
357
  const autoDetectedBoolean: Array<keyof _T> = []
@@ -0,0 +1,375 @@
1
+ import type { Database } from "bun:sqlite"
2
+ import type { ColumnDefinition, MigrationOptions } from "./types"
3
+ import { buildColumnSQL } from "./lib/table/buildColumnSQL"
4
+ import { SqliteLogger } from "./utils/logger"
5
+
6
+ export interface TableColumn {
7
+ cid: number
8
+ name: string
9
+ type: string
10
+ notnull: number
11
+ dflt_value: string | number | null
12
+ pk: number
13
+ }
14
+
15
+ export interface IndexInfo {
16
+ name: string
17
+ sql: string | null
18
+ unique: boolean
19
+ origin: string
20
+ partial: number
21
+ }
22
+
23
+ export interface ForeignKeyInfo {
24
+ id: number
25
+ seq: number
26
+ table: string
27
+ from: string
28
+ to: string
29
+ on_update: string
30
+ on_delete: string
31
+ match: string
32
+ }
33
+
34
+ /**
35
+ * Get the current schema of a table
36
+ */
37
+ export function getTableColumns(db: Database, tableName: string): TableColumn[] {
38
+ const stmt = db.prepare(`PRAGMA table_info("${tableName}")`)
39
+ return stmt.all() as TableColumn[]
40
+ }
41
+
42
+ /**
43
+ * Get indexes for a table
44
+ */
45
+ export function getTableIndexes(db: Database, tableName: string): IndexInfo[] {
46
+ interface PragmaIndex {
47
+ seq: number
48
+ name: string
49
+ unique: number
50
+ origin: string
51
+ partial: number
52
+ }
53
+
54
+ interface SqliteMasterRow {
55
+ sql: string | null
56
+ }
57
+
58
+ const stmt = db.prepare(`PRAGMA index_list("${tableName}")`)
59
+ const indexes = stmt.all() as PragmaIndex[]
60
+
61
+ return indexes.map((idx) => {
62
+ const infoStmt = db.prepare(`PRAGMA index_info("${idx.name}")`)
63
+ infoStmt.all() // Execute but don't need the result for this use case
64
+
65
+ // Get the CREATE INDEX statement if available
66
+ const sqlStmt = db.prepare(`SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?`)
67
+ const sqlResult = sqlStmt.get(idx.name) as SqliteMasterRow | null
68
+
69
+ return {
70
+ name: idx.name,
71
+ sql: sqlResult?.sql || null,
72
+ unique: idx.unique === 1,
73
+ origin: idx.origin,
74
+ partial: idx.partial,
75
+ }
76
+ })
77
+ }
78
+
79
+ /**
80
+ * Get foreign keys for a table
81
+ */
82
+ export function getTableForeignKeys(db: Database, tableName: string): ForeignKeyInfo[] {
83
+ const stmt = db.prepare(`PRAGMA foreign_key_list("${tableName}")`)
84
+ return stmt.all() as ForeignKeyInfo[]
85
+ }
86
+
87
+ /**
88
+ * Get triggers for a table
89
+ */
90
+ export function getTableTriggers(
91
+ db: Database,
92
+ tableName: string
93
+ ): Array<{ name: string; sql: string }> {
94
+ const stmt = db.prepare(
95
+ `SELECT name, sql FROM sqlite_master WHERE type = 'trigger' AND tbl_name = ?`
96
+ )
97
+ return stmt.all(tableName) as Array<{ name: string; sql: string }>
98
+ }
99
+
100
+ /**
101
+ * Check if a table exists
102
+ */
103
+ export function tableExists(db: Database, tableName: string): boolean {
104
+ const stmt = db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
105
+ const result = stmt.get(tableName)
106
+ return result !== null
107
+ }
108
+
109
+ /**
110
+ * Compare two schemas to determine if migration is needed
111
+ */
112
+ export function schemasAreDifferent(
113
+ currentSchema: TableColumn[],
114
+ newColumns: Record<string, ColumnDefinition>,
115
+ logger: SqliteLogger
116
+ ): boolean {
117
+ logger.info("Comparing schemas")
118
+ const currentColumnNames = new Set(currentSchema.map((col) => col.name))
119
+ const newColumnNames = new Set(Object.keys(newColumns))
120
+
121
+ // Check if column count differs
122
+ if (currentColumnNames.size !== newColumnNames.size) {
123
+ logger.info("Column count differs")
124
+ return true
125
+ }
126
+
127
+ // Check if all column names match
128
+ for (const name of newColumnNames) {
129
+ if (!currentColumnNames.has(name)) {
130
+ logger.info(`Column ${name} does not exist`)
131
+ return true
132
+ }
133
+ logger.debug(`Column ${name} exists`)
134
+ }
135
+
136
+ // Check column definitions
137
+ for (const currentCol of currentSchema) {
138
+ const newCol = newColumns[currentCol.name]
139
+ if (!newCol) {
140
+ logger.info(`Column ${currentCol.name} does not exist`)
141
+ return true
142
+ }
143
+
144
+ // For primary key columns, SQLite auto-adds AUTOINCREMENT which we need to account for
145
+ const isCurrentPK = currentCol.pk === 1
146
+ const isNewPK = newCol.primaryKey === true || newCol.autoincrement === true
147
+
148
+ // If both are primary keys, consider them the same (don't check other constraints)
149
+ if (isCurrentPK && isNewPK) {
150
+ logger.info(`Column ${currentCol.name} is a primary key`)
151
+ continue
152
+ }
153
+
154
+ if (isCurrentPK !== isNewPK) {
155
+ logger.info(
156
+ `Column ${currentCol.name} primary key status differs (current: ${isCurrentPK}, new: ${isNewPK})`
157
+ )
158
+ return true
159
+ }
160
+
161
+ // Check if NOT NULL constraint differs (ignore for primary keys as they're always NOT NULL)
162
+ if (!isCurrentPK) {
163
+ const isCurrentNotNull = currentCol.notnull === 1
164
+ const isNewNotNull = newCol.notNull === true
165
+ if (isCurrentNotNull !== isNewNotNull) {
166
+ logger.info(
167
+ `Column ${currentCol.name} NOT NULL constraint differs (current: ${isCurrentNotNull}, new: ${isNewNotNull})`
168
+ )
169
+ return true
170
+ }
171
+ }
172
+
173
+ // Basic type comparison (normalize types)
174
+ const normalizeType = (type: string) => type.toUpperCase().split("(")[0].trim()
175
+ const currentType = normalizeType(currentCol.type)
176
+ const newType = normalizeType(newCol.type)
177
+
178
+ // Handle type aliases
179
+ const typeAliases: Record<string, string> = {
180
+ INT: "INTEGER",
181
+ BOOL: "INTEGER",
182
+ BOOLEAN: "INTEGER",
183
+ JSON: "TEXT",
184
+ VARCHAR: "TEXT",
185
+ CHAR: "TEXT",
186
+ DATETIME: "TEXT",
187
+ DATE: "TEXT",
188
+ TIMESTAMP: "TEXT",
189
+ }
190
+
191
+ const normalizedCurrentType = typeAliases[currentType] || currentType
192
+ const normalizedNewType = typeAliases[newType] || newType
193
+
194
+ if (normalizedCurrentType !== normalizedNewType) {
195
+ logger.info(`Column ${currentCol.name} type differs`)
196
+ return true
197
+ }
198
+ }
199
+
200
+ logger.info("No schema changes detected")
201
+ return false
202
+ }
203
+
204
+ /**
205
+ * Generate column mapping for data migration
206
+ */
207
+ export function generateColumnMapping(
208
+ currentSchema: TableColumn[],
209
+ newColumns: Record<string, ColumnDefinition>
210
+ ): { selectColumns: string[]; insertColumns: string[] } {
211
+ const currentColumnNames = new Set(currentSchema.map((col) => col.name))
212
+ const newColumnNames = Object.keys(newColumns)
213
+
214
+ // Find common columns
215
+ const commonColumns = newColumnNames.filter((name) => currentColumnNames.has(name))
216
+
217
+ return {
218
+ selectColumns: commonColumns,
219
+ insertColumns: commonColumns,
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Migrate a table to a new schema
225
+ */
226
+ export function migrateTable(
227
+ db: Database,
228
+ tableName: string,
229
+ newColumns: Record<string, ColumnDefinition>,
230
+ options: MigrationOptions = {},
231
+ tableConstraints: string[] = [],
232
+ migrationLog: SqliteLogger
233
+ ): void {
234
+ const { preserveData = true, onConflict = "fail", tempTableSuffix = "_migration_temp" } = options
235
+
236
+ migrationLog.info(`Starting migration for table: ${tableName}`)
237
+
238
+ // Get current table info
239
+ const currentSchema = getTableColumns(db, tableName)
240
+ const indexes = getTableIndexes(db, tableName)
241
+ const triggers = getTableTriggers(db, tableName)
242
+
243
+ // Check if migration is needed
244
+ if (!schemasAreDifferent(currentSchema, newColumns, migrationLog)) {
245
+ migrationLog.info(`No migration needed for table: ${tableName}`)
246
+ return
247
+ }
248
+
249
+ const tempTableName = `${tableName}${tempTableSuffix}`
250
+
251
+ // Start transaction for atomic migration
252
+ db.transaction(() => {
253
+ try {
254
+ // Step 1: Create temporary table with new schema
255
+ migrationLog.debug(`Creating temporary table: ${tempTableName}`)
256
+ const columnDefs: string[] = []
257
+
258
+ for (const [colName, colDef] of Object.entries(newColumns)) {
259
+ const sqlDef = buildColumnSQL(colName, colDef)
260
+ columnDefs.push(`"${colName}" ${sqlDef}`)
261
+ }
262
+
263
+ // Include table constraints if provided
264
+ const allDefinitions =
265
+ tableConstraints.length > 0
266
+ ? [...columnDefs, ...tableConstraints].join(", ")
267
+ : columnDefs.join(", ")
268
+
269
+ const createTempTableSql = `CREATE TABLE "${tempTableName}" (${allDefinitions})`
270
+ db.run(createTempTableSql)
271
+
272
+ // Step 2: Copy data if requested
273
+ if (preserveData && currentSchema.length > 0) {
274
+ const { selectColumns, insertColumns } = generateColumnMapping(currentSchema, newColumns)
275
+
276
+ if (selectColumns.length > 0) {
277
+ migrationLog.debug(`Copying data from ${tableName} to ${tempTableName}`)
278
+
279
+ const quotedSelectCols = selectColumns.map((col) => `"${col}"`).join(", ")
280
+ const quotedInsertCols = insertColumns.map((col) => `"${col}"`).join(", ")
281
+
282
+ let copySql = `INSERT INTO "${tempTableName}" (${quotedInsertCols})
283
+ SELECT ${quotedSelectCols} FROM "${tableName}"`
284
+
285
+ if (onConflict === "ignore") {
286
+ copySql = `INSERT OR IGNORE INTO "${tempTableName}" (${quotedInsertCols})
287
+ SELECT ${quotedSelectCols} FROM "${tableName}"`
288
+ } else if (onConflict === "replace") {
289
+ copySql = `INSERT OR REPLACE INTO "${tempTableName}" (${quotedInsertCols})
290
+ SELECT ${quotedSelectCols} FROM "${tableName}"`
291
+ }
292
+
293
+ db.run(copySql)
294
+ }
295
+ }
296
+
297
+ // Step 3: Drop the original table
298
+ migrationLog.debug(`Dropping original table: ${tableName}`)
299
+
300
+ // Temporarily disable foreign key constraints
301
+ interface ForeignKeyStatus {
302
+ foreign_keys: number
303
+ }
304
+ const fkStatus = db.prepare("PRAGMA foreign_keys").get() as ForeignKeyStatus | null
305
+ if (fkStatus && fkStatus.foreign_keys === 1) {
306
+ db.run("PRAGMA foreign_keys = OFF")
307
+ }
308
+
309
+ db.run(`DROP TABLE "${tableName}"`)
310
+
311
+ // Step 4: Rename temporary table to original name
312
+ migrationLog.debug(`Renaming ${tempTableName} to ${tableName}`)
313
+ db.run(`ALTER TABLE "${tempTableName}" RENAME TO "${tableName}"`)
314
+
315
+ // Step 5: Recreate indexes
316
+ for (const index of indexes) {
317
+ if (index.sql && !index.sql.includes("sqlite_autoindex")) {
318
+ migrationLog.debug(`Recreating index: ${index.name}`)
319
+ try {
320
+ db.run(index.sql)
321
+ } catch (err) {
322
+ migrationLog.warn(`Failed to recreate index ${index.name}: ${err}`)
323
+ }
324
+ }
325
+ }
326
+
327
+ // Step 6: Recreate triggers
328
+ for (const trigger of triggers) {
329
+ migrationLog.debug(`Recreating trigger: ${trigger.name}`)
330
+ try {
331
+ db.run(trigger.sql)
332
+ } catch (err) {
333
+ migrationLog.warn(`Failed to recreate trigger ${trigger.name}: ${err}`)
334
+ }
335
+ }
336
+
337
+ // Re-enable foreign key constraints if they were enabled
338
+ if (fkStatus && fkStatus.foreign_keys === 1) {
339
+ db.run("PRAGMA foreign_keys = ON")
340
+ }
341
+
342
+ migrationLog.info(`Successfully migrated table: ${tableName}`)
343
+ } catch (error) {
344
+ migrationLog.error(`Migration failed for table ${tableName}: ${error}`)
345
+ throw error
346
+ }
347
+ })()
348
+ }
349
+
350
+ /**
351
+ * Check if migration is needed and perform if necessary
352
+ */
353
+ export function checkAndMigrate(
354
+ db: Database,
355
+ tableName: string,
356
+ newColumns: Record<string, ColumnDefinition>,
357
+ migrationLog: SqliteLogger,
358
+ options?: MigrationOptions,
359
+ tableConstraints: string[] = []
360
+ ): boolean {
361
+ const logger = new SqliteLogger(tableName, migrationLog.getBaseLogger())
362
+
363
+ if (!tableExists(db, tableName)) {
364
+ return false // No existing table, no migration needed
365
+ }
366
+
367
+ const currentSchema = getTableColumns(db, tableName)
368
+
369
+ if (schemasAreDifferent(currentSchema, newColumns, logger)) {
370
+ migrateTable(db, tableName, newColumns, options, tableConstraints, migrationLog)
371
+ return true
372
+ }
373
+
374
+ return false
375
+ }
@@ -175,8 +175,36 @@ 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
 
205
+ // Use the protected static helper inherited from WhereQueryBuilder: `safeStringify`
206
+ // (Removed duplicate implementation to avoid static-side conflicts with the base class.)
207
+
180
208
  /**
181
209
  * Execute the query and return all matching rows
182
210
  *
@@ -187,20 +215,22 @@ export class SelectQueryBuilder<T extends Record<string, unknown>> extends Where
187
215
  const hasRegex = this.hasRegexConditions()
188
216
  const [query, params] = this.buildSelectQuery(!hasRegex)
189
217
 
218
+ this.logSelectStart("all", { query, params })
190
219
  this.selectLog.query("SELECT", query, params)
191
220
 
192
221
  const rows = this.getDb()
193
222
  .prepare(query)
194
223
  .all(...params) as T[]
195
-
196
224
  this.selectLog.result("SELECT", rows.length)
197
225
 
198
- // Transform rows (JSON/Boolean parsing)
199
226
  const transformed = this.transformRowsFromDb(rows)
200
-
201
- // Apply client-side operations if needed
202
227
  const result = hasRegex ? this.applyClientSideOperations(transformed) : transformed
203
228
 
229
+ this.logSelectReturn("all", {
230
+ length: result.length,
231
+ sample: result[0] ?? null,
232
+ })
233
+
204
234
  this.reset()
205
235
  return result
206
236
  }
@@ -211,42 +241,43 @@ export class SelectQueryBuilder<T extends Record<string, unknown>> extends Where
211
241
  * Respects LIMIT if set, otherwise adds LIMIT 1 for efficiency
212
242
  */
213
243
  get(): T | null {
214
- // If no regex and no explicit limit, optimize with LIMIT 1
215
244
  if (!this.hasRegexConditions() && this.limitValue === undefined) {
216
245
  const [query, params] = this.buildSelectQuery(true)
217
246
  const optimizedQuery = `${query} LIMIT 1`
218
247
 
248
+ this.logSelectStart("get", { optimizedQuery, params })
219
249
  this.selectLog.query("SELECT (get)", optimizedQuery, params)
220
250
 
221
251
  const row = this.getDb()
222
252
  .prepare(optimizedQuery)
223
253
  .get(...params) as T | null
224
-
225
254
  this.selectLog.result("SELECT (get)", row ? 1 : 0)
226
255
 
227
256
  const result = row ? this.transformRowFromDb(row) : null
257
+ this.logSelectReturn("get", { returned: result })
258
+
228
259
  this.reset()
229
260
  return result
230
261
  }
231
262
 
232
- // If limit is set or regex conditions exist, use standard flow
233
263
  if (!this.hasRegexConditions()) {
234
264
  const [query, params] = this.buildSelectQuery(true)
235
265
 
266
+ this.logSelectStart("get", { query, params })
236
267
  this.selectLog.query("SELECT (get)", query, params)
237
268
 
238
269
  const row = this.getDb()
239
270
  .prepare(query)
240
271
  .get(...params) as T | null
241
-
242
272
  this.selectLog.result("SELECT (get)", row ? 1 : 0)
243
273
 
244
274
  const result = row ? this.transformRowFromDb(row) : null
275
+ this.logSelectReturn("get", { returned: result })
276
+
245
277
  this.reset()
246
278
  return result
247
279
  }
248
280
 
249
- // Has regex conditions - fall back to all() and take first
250
281
  const results = this.all()
251
282
  return results[0] ?? null
252
283
  }
@@ -268,21 +299,23 @@ export class SelectQueryBuilder<T extends Record<string, unknown>> extends Where
268
299
  */
269
300
  count(): number {
270
301
  if (!this.hasRegexConditions()) {
271
- // Use SQL COUNT for efficiency
272
302
  const [whereClause, whereParams] = this.buildWhereClause()
273
303
  const query = `SELECT COUNT(*) AS __count FROM ${quoteIdentifier(this.getTableName())}${whereClause}`
274
304
 
305
+ this.logSelectStart("count", { query, params: whereParams })
275
306
  this.selectLog.query("COUNT", query, whereParams)
276
307
 
277
308
  const result = this.getDb()
278
309
  .prepare(query)
279
310
  .get(...whereParams) as { __count: number } | null
280
311
 
312
+ const count = result?.__count ?? 0
281
313
  this.reset()
282
- return result?.__count ?? 0
314
+
315
+ this.logSelectReturn("count", { returned: count })
316
+ return count
283
317
  }
284
318
 
285
- // Has regex conditions - count client-side
286
319
  const results = this.all()
287
320
  return results.length
288
321
  }
@@ -292,44 +325,58 @@ export class SelectQueryBuilder<T extends Record<string, unknown>> extends Where
292
325
  */
293
326
  exists(): boolean {
294
327
  if (!this.hasRegexConditions()) {
295
- // Use EXISTS for efficiency
296
328
  const [whereClause, whereParams] = this.buildWhereClause()
297
329
  const subquery = `SELECT 1 FROM ${quoteIdentifier(this.getTableName())}${whereClause} LIMIT 1`
298
330
  const query = `SELECT EXISTS(${subquery}) AS __exists`
299
331
 
332
+ this.logSelectStart("exists", { query, params: whereParams })
300
333
  this.selectLog.query("EXISTS", query, whereParams)
301
334
 
302
335
  const result = this.getDb()
303
336
  .prepare(query)
304
337
  .get(...whereParams) as { __exists: number } | null
305
338
 
339
+ const exists = Boolean(result?.__exists)
306
340
  this.reset()
307
- return Boolean(result?.__exists)
341
+
342
+ this.logSelectReturn("exists", { returned: exists })
343
+ return exists
308
344
  }
309
345
 
310
- // Has regex conditions - check client-side
311
346
  return this.count() > 0
312
347
  }
313
348
 
349
+ private logColumnReturn(method: "value" | "pluck", column: string, returned: unknown): void {
350
+ this.selectLog.info(
351
+ `${method} | column=${column} returned=${WhereQueryBuilder.safeStringify(returned)}`
352
+ )
353
+ }
354
+
314
355
  /**
315
- * Get a single column value from the first matching row
356
+ * Get an array of values from a single column
316
357
  *
317
358
  * @example
318
- * const name = table.where({ id: 1 }).value("name")
359
+ * const emails = table.where({ active: true }).pluck("email")
319
360
  */
320
- value<K extends keyof T>(column: K): T[K] | null {
321
- const row = this.first()
322
- return row ? row[column] : null
361
+ pluck<K extends keyof T>(column: K): T[K][] {
362
+ const rows = this.all() || []
363
+ const values = rows.map((row) => row[column])
364
+
365
+ this.logColumnReturn("pluck", String(column), values)
366
+ return values
323
367
  }
324
368
 
325
369
  /**
326
- * Get an array of values from a single column
370
+ * Get a single column value from the first matching row
327
371
  *
328
372
  * @example
329
- * const emails = table.where({ active: true }).pluck("email")
373
+ * const name = table.where({ id: 1 }).value("name")
330
374
  */
331
- pluck<K extends keyof T>(column: K): T[K][] {
332
- const rows = this.all()
333
- return rows.map((row) => row[column])
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
334
381
  }
335
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
@@ -54,6 +74,16 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
54
74
 
55
75
  // ===== Public WHERE Methods =====
56
76
 
77
+ // Helper to stringify values safely (converts RegExp to string and falls back)
78
+ protected static safeStringify(obj: unknown): string {
79
+ try {
80
+ const dat = JSON.stringify(obj, (_k, v) => (v instanceof RegExp ? v.toString() : v))
81
+ return truncate(dat, 100)
82
+ } catch {
83
+ return truncate(String(obj), 100)
84
+ }
85
+ }
86
+
57
87
  /**
58
88
  * Add simple equality conditions to the WHERE clause
59
89
  *
@@ -66,6 +96,8 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
66
96
  * // WHERE "deleted_at" IS NULL
67
97
  */
68
98
  where(conditions: WhereCondition<T>): this {
99
+ this.logWhere("where", { conditions })
100
+
69
101
  for (const [column, value] of Object.entries(conditions)) {
70
102
  this.removeExistingCondition(column)
71
103
 
@@ -76,6 +108,8 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
76
108
  this.state.whereParams.push(this.toSqliteValue(value))
77
109
  }
78
110
  }
111
+
112
+ this.logWhereState("where")
79
113
  return this
80
114
  }
81
115
 
@@ -86,6 +120,9 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
86
120
  * .whereRgx({ email: /@gmail\.com$/ })
87
121
  */
88
122
  whereRgx(conditions: RegexCondition<T>): this {
123
+ // Log invocation with a safe serializer for regex values
124
+ this.log.info(`whereRgx | conditions=${WhereQueryBuilder.safeStringify(conditions)}`)
125
+
89
126
  for (const [column, value] of Object.entries(conditions)) {
90
127
  this.removeExistingCondition(column)
91
128
 
@@ -105,6 +142,10 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
105
142
  this.state.whereParams.push(value as SQLQueryBindings)
106
143
  }
107
144
  }
145
+
146
+ // Log resulting WHERE/regex state
147
+ this.logWhereState("whereRgx", { regexConditions: this.state.regexConditions })
148
+
108
149
  return this
109
150
  }
110
151
 
@@ -116,6 +157,9 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
116
157
  * .whereExpr("created_at > datetime('now', '-1 day')")
117
158
  */
118
159
  whereExpr(expr: string, params: SQLQueryBindings[] = []): this {
160
+ // Log invocation
161
+ this.log.info(`whereExpr | expr=${expr} params=${WhereQueryBuilder.safeStringify(params)}`)
162
+
119
163
  if (!expr || typeof expr !== "string") {
120
164
  throw new Error("whereExpr: expression must be a non-empty string")
121
165
  }
@@ -127,6 +171,13 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
127
171
  this.state.whereParams.push(...params)
128
172
  }
129
173
 
174
+ // Log resulting WHERE clause state
175
+ this.log.info(
176
+ `whereExpr | whereConditions=${WhereQueryBuilder.safeStringify(this.state.whereConditions)} params=${WhereQueryBuilder.safeStringify(
177
+ this.state.whereParams
178
+ )}`
179
+ )
180
+
130
181
  return this
131
182
  }
132
183
 
@@ -134,6 +185,8 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
134
185
  * Alias for whereExpr
135
186
  */
136
187
  whereRaw(expr: string, params: SQLQueryBindings[] = []): this {
188
+ // Log alias invocation
189
+ this.log.info(`whereRaw | expr=${expr} params=${WhereQueryBuilder.safeStringify(params)}`)
137
190
  return this.whereExpr(expr, params)
138
191
  }
139
192
 
@@ -145,6 +198,11 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
145
198
  * // WHERE "status" IN (?, ?)
146
199
  */
147
200
  whereIn(column: keyof T, values: SQLQueryBindings[]): this {
201
+ // Log invocation
202
+ this.log.info(
203
+ `whereIn | column=${String(column)} values=${WhereQueryBuilder.safeStringify(values)}`
204
+ )
205
+
148
206
  if (!Array.isArray(values) || values.length === 0) {
149
207
  throw new Error("whereIn: values must be a non-empty array")
150
208
  }
@@ -155,6 +213,13 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
155
213
  this.state.whereConditions.push(sql)
156
214
  this.state.whereParams.push(...params)
157
215
 
216
+ // Log resulting WHERE clause state
217
+ this.log.info(
218
+ `whereIn | whereConditions=${WhereQueryBuilder.safeStringify(this.state.whereConditions)} params=${WhereQueryBuilder.safeStringify(
219
+ this.state.whereParams
220
+ )}`
221
+ )
222
+
158
223
  return this
159
224
  }
160
225
 
@@ -166,6 +231,11 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
166
231
  * // WHERE "role" NOT IN (?, ?)
167
232
  */
168
233
  whereNotIn(column: keyof T, values: SQLQueryBindings[]): this {
234
+ // Log invocation
235
+ this.log.info(
236
+ `whereNotIn | column=${String(column)} values=${WhereQueryBuilder.safeStringify(values)}`
237
+ )
238
+
169
239
  if (!Array.isArray(values) || values.length === 0) {
170
240
  throw new Error("whereNotIn: values must be a non-empty array")
171
241
  }
@@ -176,6 +246,13 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
176
246
  this.state.whereConditions.push(sql)
177
247
  this.state.whereParams.push(...params)
178
248
 
249
+ // Log resulting WHERE clause state
250
+ this.log.info(
251
+ `whereNotIn | whereConditions=${WhereQueryBuilder.safeStringify(this.state.whereConditions)} params=${WhereQueryBuilder.safeStringify(
252
+ this.state.whereParams
253
+ )}`
254
+ )
255
+
179
256
  return this
180
257
  }
181
258
 
@@ -189,17 +266,20 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
189
266
  * .whereOp("name", "LIKE", "%smith%")
190
267
  */
191
268
  whereOp(column: keyof T, op: string, value: SQLQueryBindings): this {
192
- const normalizedOp = normalizeOperator(op)
193
269
  const columnStr = String(column)
270
+ this.logWhere("whereOp", { column: columnStr, op, value })
271
+
272
+ const normalizedOp = normalizeOperator(op)
194
273
 
195
- // Handle NULL special cases
196
274
  if (value === null || value === undefined) {
197
275
  if (normalizedOp === "=" || normalizedOp === "IS") {
198
276
  this.state.whereConditions.push(`${quoteIdentifier(columnStr)} IS NULL`)
277
+ this.logWhere("whereOp", { column: columnStr, added: "IS NULL" })
199
278
  return this
200
279
  }
201
280
  if (normalizedOp === "!=" || normalizedOp === "<>" || normalizedOp === "IS NOT") {
202
281
  this.state.whereConditions.push(`${quoteIdentifier(columnStr)} IS NOT NULL`)
282
+ this.logWhere("whereOp", { column: columnStr, added: "IS NOT NULL" })
203
283
  return this
204
284
  }
205
285
  }
@@ -207,6 +287,7 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
207
287
  this.state.whereConditions.push(`${quoteIdentifier(columnStr)} ${normalizedOp} ?`)
208
288
  this.state.whereParams.push(value)
209
289
 
290
+ this.logWhereState("whereOp")
210
291
  return this
211
292
  }
212
293
 
@@ -218,12 +299,26 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
218
299
  * // WHERE "age" BETWEEN ? AND ?
219
300
  */
220
301
  whereBetween(column: keyof T, min: SQLQueryBindings, max: SQLQueryBindings): this {
302
+ // Log invocation
303
+ this.log.info(
304
+ `whereBetween | column=${String(column)} min=${WhereQueryBuilder.safeStringify(min)} max=${WhereQueryBuilder.safeStringify(
305
+ max
306
+ )}`
307
+ )
308
+
221
309
  this.removeExistingCondition(String(column), "BETWEEN")
222
310
 
223
311
  const { sql, params } = buildBetweenClause(String(column), min, max, false)
224
312
  this.state.whereConditions.push(sql)
225
313
  this.state.whereParams.push(...params)
226
314
 
315
+ // Log resulting WHERE clause state
316
+ this.log.info(
317
+ `whereBetween | whereConditions=${WhereQueryBuilder.safeStringify(this.state.whereConditions)} params=${WhereQueryBuilder.safeStringify(
318
+ this.state.whereParams
319
+ )}`
320
+ )
321
+
227
322
  return this
228
323
  }
229
324
 
@@ -235,12 +330,26 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
235
330
  * // WHERE "score" NOT BETWEEN ? AND ?
236
331
  */
237
332
  whereNotBetween(column: keyof T, min: SQLQueryBindings, max: SQLQueryBindings): this {
333
+ // Log invocation
334
+ this.log.info(
335
+ `whereNotBetween | column=${String(column)} min=${WhereQueryBuilder.safeStringify(min)} max=${WhereQueryBuilder.safeStringify(
336
+ max
337
+ )}`
338
+ )
339
+
238
340
  this.removeExistingCondition(String(column), "NOT BETWEEN")
239
341
 
240
342
  const { sql, params } = buildBetweenClause(String(column), min, max, true)
241
343
  this.state.whereConditions.push(sql)
242
344
  this.state.whereParams.push(...params)
243
345
 
346
+ // Log resulting WHERE clause state
347
+ this.log.info(
348
+ `whereNotBetween | whereConditions=${WhereQueryBuilder.safeStringify(this.state.whereConditions)} params=${WhereQueryBuilder.safeStringify(
349
+ this.state.whereParams
350
+ )}`
351
+ )
352
+
244
353
  return this
245
354
  }
246
355
 
@@ -252,8 +361,19 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
252
361
  * // WHERE "deleted_at" IS NULL
253
362
  */
254
363
  whereNull(column: keyof T): this {
364
+ // Log invocation
365
+ this.log.info(`whereNull | column=${String(column)}`)
366
+
255
367
  this.removeExistingCondition(String(column))
256
368
  this.state.whereConditions.push(`${quoteIdentifier(String(column))} IS NULL`)
369
+
370
+ // Log resulting WHERE clause state
371
+ this.log.info(
372
+ `whereNull | whereConditions=${WhereQueryBuilder.safeStringify(this.state.whereConditions)} params=${WhereQueryBuilder.safeStringify(
373
+ this.state.whereParams
374
+ )}`
375
+ )
376
+
257
377
  return this
258
378
  }
259
379
 
@@ -265,8 +385,19 @@ export class WhereQueryBuilder<T extends Record<string, unknown>> extends BaseQu
265
385
  * // WHERE "email" IS NOT NULL
266
386
  */
267
387
  whereNotNull(column: keyof T): this {
388
+ // Log invocation
389
+ this.log.info(`whereNotNull | column=${String(column)}`)
390
+
268
391
  this.removeExistingCondition(String(column))
269
392
  this.state.whereConditions.push(`${quoteIdentifier(String(column))} IS NOT NULL`)
393
+
394
+ // Log resulting WHERE clause state
395
+ this.log.info(
396
+ `whereNotNull | whereConditions=${WhereQueryBuilder.safeStringify(this.state.whereConditions)} params=${WhereQueryBuilder.safeStringify(
397
+ this.state.whereParams
398
+ )}`
399
+ )
400
+
270
401
  return this
271
402
  }
272
403
  }
package/src/types.ts CHANGED
@@ -237,6 +237,17 @@ 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
+ /** Whether to drop columns not in new schema (default: true) */
244
+ dropMissingColumns?: boolean
245
+ /** How to handle constraint violations during data copy */
246
+ onConflict?: "fail" | "ignore" | "replace"
247
+ /** Suffix for temporary table during migration (default: '_migration_temp') */
248
+ tempTableSuffix?: string
249
+ }
250
+
240
251
  export interface TableOptions<T> {
241
252
  /** Add IF NOT EXISTS clause */
242
253
  ifNotExists?: boolean
@@ -248,6 +259,8 @@ export interface TableOptions<T> {
248
259
  temporary?: boolean
249
260
  /** Table comment */
250
261
  comment?: string
262
+ /** Enable automatic schema migration (default: true) */
263
+ migrate?: boolean | MigrationOptions
251
264
 
252
265
  parser?: Partial<Parser<T>>
253
266
  }
@@ -461,9 +474,9 @@ export const column = {
461
474
  /**
462
475
  * Create a foreign key reference column
463
476
  */
464
- foreignKey: (
477
+ foreignKey: <_T extends Record<string, unknown>>(
465
478
  refTable: string,
466
- refColumn = "id",
479
+ refColumn: keyof _T = "id",
467
480
  constraints?: ColumnConstraints & {
468
481
  onDelete?: ForeignKeyAction
469
482
  onUpdate?: ForeignKeyAction
@@ -473,7 +486,7 @@ export const column = {
473
486
  type: constraints?.type || SQLiteTypes.INTEGER,
474
487
  references: {
475
488
  table: refTable,
476
- column: refColumn,
489
+ column: String(refColumn),
477
490
  onDelete: constraints?.onDelete,
478
491
  onUpdate: constraints?.onUpdate,
479
492
  },
@@ -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
+ }