@dockstat/sqlite-wrapper 1.3.4 → 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,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 Wrong packing** — 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.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,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,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
+ }