@pineliner/odb-client 1.0.7 → 1.0.9

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.
@@ -5,6 +5,7 @@ import type {
5
5
  QueryResult,
6
6
  PreparedStatement,
7
7
  LibSQLConfig,
8
+ QueryOptions,
8
9
  } from '../types'
9
10
  import {
10
11
  convertTemplateToQuery,
@@ -17,6 +18,48 @@ import {
17
18
  where
18
19
  } from '../sql-template'
19
20
 
21
+ /**
22
+ * Parse JSON columns in query results
23
+ * Only parses if the value is a string (to avoid double-parsing)
24
+ */
25
+ function parseJsonColumns<T = any>(rows: any[], jsonColumns?: string[]): T[] {
26
+ if (!jsonColumns || jsonColumns.length === 0) {
27
+ return rows
28
+ }
29
+
30
+ return rows.map(row => {
31
+ const parsed = { ...row }
32
+ for (const col of jsonColumns) {
33
+ if (col in parsed && typeof parsed[col] === 'string') {
34
+ try {
35
+ parsed[col] = JSON.parse(parsed[col])
36
+ } catch {
37
+ // Keep original value if parsing fails
38
+ }
39
+ }
40
+ }
41
+ return parsed
42
+ })
43
+ }
44
+
45
+ /**
46
+ * Stringify JSON parameters for INSERT/UPDATE queries
47
+ * Only stringifies if the value is an object/array (not already a string)
48
+ */
49
+ function stringifyJsonParams(params: any[], stringifyParams?: Record<string, number>): any[] {
50
+ if (!stringifyParams || Object.keys(stringifyParams).length === 0) {
51
+ return params
52
+ }
53
+
54
+ const result = [...params]
55
+ for (const [_columnName, index] of Object.entries(stringifyParams)) {
56
+ if (index < result.length && result[index] != null && typeof result[index] === 'object') {
57
+ result[index] = JSON.stringify(result[index])
58
+ }
59
+ }
60
+ return result
61
+ }
62
+
20
63
  /**
21
64
  * LibSQL adapter for DatabaseManager
22
65
  * Wraps @libsql/client with Connection interface
@@ -117,15 +160,15 @@ class LibSQLConnection implements Connection {
117
160
  /**
118
161
  * Query with SQL string and parameters (alias for execute)
119
162
  */
120
- async query<T = any>(sql: string, params: any[] = []): Promise<QueryResult<T>> {
121
- return this.execute(sql, params) as Promise<QueryResult<T>>
163
+ async query<T = any>(sql: string, params: any[] = [], options?: QueryOptions): Promise<QueryResult<T>> {
164
+ return this.execute(sql, params, options) as Promise<QueryResult<T>>
122
165
  }
123
166
 
124
167
  /**
125
168
  * Execute SQL with parameters
126
169
  * Supports both formats: execute(sql, params) and execute({sql, args})
127
170
  */
128
- async execute(sql: string | { sql: string; args?: any[] }, params: any[] = []): Promise<QueryResult> {
171
+ async execute(sql: string | { sql: string; args?: any[] }, params: any[] = [], options?: QueryOptions): Promise<QueryResult> {
129
172
  try {
130
173
  const target = this.txClient || this.client
131
174
 
@@ -137,19 +180,36 @@ class LibSQLConnection implements Connection {
137
180
  console.log('[LibSQL] Executing SQL:', sql)
138
181
  console.log('[LibSQL] With params:', params)
139
182
  }
140
- query = { sql, args: params }
183
+ let args = params
184
+ // Stringify JSON parameters if specified
185
+ if (options?.stringifyParams) {
186
+ args = stringifyJsonParams(args, options.stringifyParams)
187
+ }
188
+ query = { sql, args }
141
189
  } else {
142
190
  if (process.env.DEBUG_SQL) {
143
191
  console.log('[LibSQL] Executing SQL:', sql.sql)
144
192
  console.log('[LibSQL] With args:', sql.args)
145
193
  }
146
- query = { sql: sql.sql, args: sql.args || [] }
194
+ let args = sql.args || []
195
+ // Stringify JSON parameters if specified
196
+ if (options?.stringifyParams) {
197
+ args = stringifyJsonParams(args, options.stringifyParams)
198
+ }
199
+ query = { sql: sql.sql, args }
147
200
  }
148
201
 
149
202
  const result = await target.execute(query)
150
203
 
204
+ let rows = result.rows as any[]
205
+
206
+ // Parse JSON columns if specified
207
+ if (options?.jsonColumns) {
208
+ rows = parseJsonColumns(rows, options.jsonColumns)
209
+ }
210
+
151
211
  return {
152
- rows: result.rows as any[],
212
+ rows,
153
213
  rowsAffected: Number(result.rowsAffected),
154
214
  lastInsertRowid: result.lastInsertRowid
155
215
  ? BigInt(result.lastInsertRowid.toString())
@@ -6,6 +6,7 @@ import type {
6
6
  QueryResult,
7
7
  PreparedStatement,
8
8
  ODBLiteConfig,
9
+ QueryOptions,
9
10
  } from '../types'
10
11
  import {
11
12
  convertTemplateToQuery,
@@ -18,6 +19,48 @@ import {
18
19
  where
19
20
  } from '../sql-template'
20
21
 
22
+ /**
23
+ * Parse JSON columns in query results
24
+ * Only parses if the value is a string (to avoid double-parsing)
25
+ */
26
+ function parseJsonColumns<T = any>(rows: any[], jsonColumns?: string[]): T[] {
27
+ if (!jsonColumns || jsonColumns.length === 0) {
28
+ return rows
29
+ }
30
+
31
+ return rows.map(row => {
32
+ const parsed = { ...row }
33
+ for (const col of jsonColumns) {
34
+ if (col in parsed && typeof parsed[col] === 'string') {
35
+ try {
36
+ parsed[col] = JSON.parse(parsed[col])
37
+ } catch {
38
+ // Keep original value if parsing fails
39
+ }
40
+ }
41
+ }
42
+ return parsed
43
+ })
44
+ }
45
+
46
+ /**
47
+ * Stringify JSON parameters for INSERT/UPDATE queries
48
+ * Only stringifies if the value is an object/array (not already a string)
49
+ */
50
+ function stringifyJsonParams(params: any[], stringifyParams?: Record<string, number>): any[] {
51
+ if (!stringifyParams || Object.keys(stringifyParams).length === 0) {
52
+ return params
53
+ }
54
+
55
+ const result = [...params]
56
+ for (const [_columnName, index] of Object.entries(stringifyParams)) {
57
+ if (index < result.length && result[index] != null && typeof result[index] === 'object') {
58
+ result[index] = JSON.stringify(result[index])
59
+ }
60
+ }
61
+ return result
62
+ }
63
+
21
64
  /**
22
65
  * ODB-Lite adapter for DatabaseManager
23
66
  * Wraps ServiceClient and ODBLiteClient with Connection interface
@@ -162,15 +205,19 @@ class ODBLiteConnection implements Connection {
162
205
  /**
163
206
  * Query with SQL string and parameters (alias for execute)
164
207
  */
165
- async query<T = any>(sql: string, params: any[] = []): Promise<QueryResult<T>> {
166
- return this.execute(sql, params) as Promise<QueryResult<T>>
208
+ async query<T = any>(sql: string, params: any[] = [], options?: QueryOptions): Promise<QueryResult<T>> {
209
+ return this.execute(sql, params, options) as Promise<QueryResult<T>>
167
210
  }
168
211
 
169
212
  /**
170
213
  * Execute SQL with parameters
171
214
  */
172
- async execute(sql: string | { sql: string; args?: any[] }, params: any[] = []): Promise<QueryResult> {
215
+ async execute(sql: string | { sql: string; args?: any[] }, params: any[] = [], options?: QueryOptions): Promise<QueryResult> {
173
216
  try {
217
+ let rows: any[]
218
+ let rowsAffected: number
219
+ let lastInsertRowid: any
220
+
174
221
  // Handle object format { sql, args }
175
222
  if (typeof sql === 'object') {
176
223
  // Debug logging for SQL errors
@@ -178,25 +225,41 @@ class ODBLiteConnection implements Connection {
178
225
  console.log('[ODBLite] Executing SQL:', sql.sql)
179
226
  console.log('[ODBLite] With args:', sql.args)
180
227
  }
181
- const result = await this.client.sql.execute(sql.sql, sql.args || [])
182
- return {
183
- rows: result.rows,
184
- rowsAffected: result.rowsAffected || 0,
185
- lastInsertRowid: (result as any).lastInsertRowid,
228
+ let args = sql.args || []
229
+ // Stringify JSON parameters if specified
230
+ if (options?.stringifyParams) {
231
+ args = stringifyJsonParams(args, options.stringifyParams)
232
+ }
233
+ const result = await this.client.sql.execute(sql.sql, args)
234
+ rows = result.rows
235
+ rowsAffected = result.rowsAffected || 0
236
+ lastInsertRowid = (result as any).lastInsertRowid
237
+ } else {
238
+ // Handle string format
239
+ if (process.env.DEBUG_SQL) {
240
+ console.log('[ODBLite] Executing SQL:', sql)
241
+ console.log('[ODBLite] With params:', params)
242
+ }
243
+ let args = params
244
+ // Stringify JSON parameters if specified
245
+ if (options?.stringifyParams) {
246
+ args = stringifyJsonParams(args, options.stringifyParams)
186
247
  }
248
+ const result = await this.client.sql.execute(sql, args)
249
+ rows = result.rows
250
+ rowsAffected = result.rowsAffected || 0
251
+ lastInsertRowid = (result as any).lastInsertRowid
187
252
  }
188
253
 
189
- // Handle string format
190
- if (process.env.DEBUG_SQL) {
191
- console.log('[ODBLite] Executing SQL:', sql)
192
- console.log('[ODBLite] With params:', params)
254
+ // Parse JSON columns if specified
255
+ if (options?.jsonColumns) {
256
+ rows = parseJsonColumns(rows, options.jsonColumns)
193
257
  }
194
- const result = await this.client.sql.execute(sql, params)
195
258
 
196
259
  return {
197
- rows: result.rows,
198
- rowsAffected: result.rowsAffected || 0,
199
- lastInsertRowid: (result as any).lastInsertRowid,
260
+ rows,
261
+ rowsAffected,
262
+ lastInsertRowid,
200
263
  }
201
264
  } catch (error: any) {
202
265
  // Re-throw the original error to let the application handle it
@@ -25,6 +25,32 @@ export interface QueryResult<T = any> {
25
25
  lastInsertRowid?: number | bigint
26
26
  }
27
27
 
28
+ /**
29
+ * Query options for fine-grained control
30
+ */
31
+ export interface QueryOptions {
32
+ /**
33
+ * List of column names that should be auto-parsed as JSON when reading
34
+ * Only applies to columns that contain stringified JSON in SELECT queries
35
+ *
36
+ * @example
37
+ * await conn.query('SELECT * FROM suppliers', [], { jsonColumns: ['connections', 'config'] })
38
+ */
39
+ jsonColumns?: string[]
40
+
41
+ /**
42
+ * Automatically stringify parameters for JSON columns in UPDATE/INSERT queries
43
+ * Maps column names to parameter indices (0-based)
44
+ *
45
+ * @example
46
+ * // UPDATE suppliers SET connections = ?, updated_at = ? WHERE id = ?
47
+ * await conn.execute(query, [connections, now, id], {
48
+ * stringifyParams: { connections: 0 } // Auto-stringify first parameter
49
+ * })
50
+ */
51
+ stringifyParams?: Record<string, number>
52
+ }
53
+
28
54
  /**
29
55
  * Unified database connection interface
30
56
  * All backends implement this through adapters
@@ -43,8 +69,8 @@ export interface Connection {
43
69
  sql(value: any, ...keys: string[]): any
44
70
 
45
71
  // Standard query methods (return QueryResult)
46
- query<T = any>(sql: string, params?: any[]): Promise<QueryResult<T>>
47
- execute(sql: string | { sql: string; args?: any[] }, params?: any[]): Promise<QueryResult>
72
+ query<T = any>(sql: string, params?: any[], options?: QueryOptions): Promise<QueryResult<T>>
73
+ execute(sql: string | { sql: string; args?: any[] }, params?: any[], options?: QueryOptions): Promise<QueryResult>
48
74
  prepare(sql: string): PreparedStatement
49
75
 
50
76
  // Transaction support
package/src/index.ts CHANGED
@@ -32,6 +32,7 @@ export {
32
32
  // ORM exports (Drizzle-like query builder)
33
33
  export {
34
34
  ORM,
35
+ Transaction as ORMTransaction,
35
36
  createORM,
36
37
  eq,
37
38
  ne,
package/src/orm/index.ts CHANGED
@@ -461,6 +461,129 @@ class TableQueryBuilder {
461
461
  }
462
462
  }
463
463
 
464
+ // ============================================================
465
+ // MANUAL TRANSACTION CLASS
466
+ // ============================================================
467
+
468
+ /**
469
+ * Manual Transaction Control
470
+ * Allows explicit commit/rollback control
471
+ */
472
+ export class Transaction {
473
+ private orm: ORM
474
+ private isCommitted: boolean = false
475
+ private isRolledBack: boolean = false
476
+ private db: Connection
477
+ private savepointName?: string
478
+
479
+ constructor(db: Connection, transactionDepth: number = 0, savepointName?: string) {
480
+ this.db = db
481
+ this.orm = new ORM(db, transactionDepth)
482
+ this.savepointName = savepointName
483
+ }
484
+
485
+ /**
486
+ * Access ORM methods (select, insert, update, delete)
487
+ */
488
+ select(fields?: Record<string, any>) {
489
+ this.checkNotFinalized()
490
+ return this.orm.select(fields)
491
+ }
492
+
493
+ insert(tableName: string) {
494
+ this.checkNotFinalized()
495
+ return this.orm.insert(tableName)
496
+ }
497
+
498
+ update(tableName: string) {
499
+ this.checkNotFinalized()
500
+ return this.orm.update(tableName)
501
+ }
502
+
503
+ delete(tableName: string) {
504
+ this.checkNotFinalized()
505
+ return this.orm.delete(tableName)
506
+ }
507
+
508
+ execute(sql: string, params?: any[]) {
509
+ this.checkNotFinalized()
510
+ return this.orm.execute(sql, params)
511
+ }
512
+
513
+ /**
514
+ * Create nested transaction (savepoint)
515
+ */
516
+ async transaction(): Promise<Transaction>
517
+ async transaction<T>(fn: (tx: ORM) => Promise<T>): Promise<T>
518
+ async transaction<T>(fn?: (tx: ORM) => Promise<T>): Promise<T | Transaction> {
519
+ this.checkNotFinalized()
520
+
521
+ if (fn) {
522
+ // Callback-style nested transaction
523
+ return await this.orm.transaction(fn)
524
+ } else {
525
+ // Manual-style nested transaction
526
+ return await this.orm.transaction()
527
+ }
528
+ }
529
+
530
+ /**
531
+ * Commit the transaction
532
+ */
533
+ async commit(): Promise<void> {
534
+ if (this.isCommitted) {
535
+ throw new Error('Transaction already committed')
536
+ }
537
+ if (this.isRolledBack) {
538
+ throw new Error('Transaction already rolled back')
539
+ }
540
+
541
+ if (this.savepointName) {
542
+ // Release savepoint for nested transaction
543
+ await this.db.execute(`RELEASE SAVEPOINT ${this.savepointName}`, [])
544
+ } else {
545
+ // Commit top-level transaction
546
+ await this.db.execute('COMMIT', [])
547
+ }
548
+
549
+ this.isCommitted = true
550
+ }
551
+
552
+ /**
553
+ * Rollback the transaction
554
+ */
555
+ async rollback(): Promise<void> {
556
+ if (this.isCommitted) {
557
+ throw new Error('Transaction already committed')
558
+ }
559
+ if (this.isRolledBack) {
560
+ throw new Error('Transaction already rolled back')
561
+ }
562
+
563
+ if (this.savepointName) {
564
+ // Rollback to savepoint for nested transaction
565
+ await this.db.execute(`ROLLBACK TO SAVEPOINT ${this.savepointName}`, [])
566
+ } else {
567
+ // Rollback top-level transaction
568
+ await this.db.execute('ROLLBACK', [])
569
+ }
570
+
571
+ this.isRolledBack = true
572
+ }
573
+
574
+ /**
575
+ * Check if transaction is finalized
576
+ */
577
+ private checkNotFinalized() {
578
+ if (this.isCommitted) {
579
+ throw new Error('Cannot perform operations on committed transaction')
580
+ }
581
+ if (this.isRolledBack) {
582
+ throw new Error('Cannot perform operations on rolled back transaction')
583
+ }
584
+ }
585
+ }
586
+
464
587
  // ============================================================
465
588
  // ORM CONNECTION WRAPPER
466
589
  // ============================================================
@@ -471,9 +594,11 @@ class TableQueryBuilder {
471
594
  */
472
595
  export class ORM {
473
596
  private db: Connection
597
+ private transactionDepth: number = 0
474
598
 
475
- constructor(db: Connection) {
599
+ constructor(db: Connection, transactionDepth: number = 0) {
476
600
  this.db = db
601
+ this.transactionDepth = transactionDepth
477
602
  }
478
603
 
479
604
  /**
@@ -525,6 +650,96 @@ export class ORM {
525
650
  execute(sql: string, params?: any[]) {
526
651
  return this.db.execute(sql, params)
527
652
  }
653
+
654
+ /**
655
+ * Execute a transaction with support for nested transactions (savepoints)
656
+ * @example
657
+ * // Callback-style transaction
658
+ * const result = await orm.transaction(async (tx) => {
659
+ * const user = await tx.insert('users').values({ name: 'Alice' }).returning()
660
+ * const profile = await tx.insert('profiles').values({
661
+ * userId: user[0].id,
662
+ * bio: 'Hello world',
663
+ * }).returning()
664
+ * return { user, profile }
665
+ * })
666
+ *
667
+ * @example
668
+ * // Manual transaction control
669
+ * const tx = await orm.transaction()
670
+ * try {
671
+ * await tx.insert('users').values({ name: 'Bob' }).execute()
672
+ * await tx.insert('profiles').values({ userId: 1, bio: 'Something' }).execute()
673
+ * await tx.commit()
674
+ * } catch (err) {
675
+ * await tx.rollback()
676
+ * throw err
677
+ * }
678
+ *
679
+ * @example
680
+ * // Nested transaction (savepoint)
681
+ * await orm.transaction(async (outer) => {
682
+ * await outer.insert('users').values({ name: 'Outer' })
683
+ *
684
+ * await outer.transaction(async (inner) => {
685
+ * await inner.insert('users').values({ name: 'Inner' })
686
+ * // If this inner block throws, only the inner part rolls back
687
+ * })
688
+ * })
689
+ */
690
+ async transaction(): Promise<Transaction>
691
+ async transaction<T>(fn: (tx: ORM) => Promise<T>): Promise<T>
692
+ async transaction<T>(fn?: (tx: ORM) => Promise<T>): Promise<T | Transaction> {
693
+ // Manual transaction control (no callback provided)
694
+ if (!fn) {
695
+ if (this.transactionDepth > 0) {
696
+ // Nested manual transaction - create savepoint
697
+ const savepointName = `sp_${this.transactionDepth}_${Date.now()}`
698
+ await this.db.execute(`SAVEPOINT ${savepointName}`, [])
699
+ return new Transaction(this.db, this.transactionDepth + 1, savepointName)
700
+ } else {
701
+ // Top-level manual transaction
702
+ await this.db.execute('BEGIN TRANSACTION', [])
703
+ return new Transaction(this.db, 1)
704
+ }
705
+ }
706
+
707
+ // Callback-style transaction
708
+ // Check if we're already in a transaction (nested transaction)
709
+ if (this.transactionDepth > 0) {
710
+ // Nested transaction - use savepoint
711
+ const savepointName = `sp_${this.transactionDepth}_${Date.now()}`
712
+
713
+ try {
714
+ // Create savepoint
715
+ await this.db.execute(`SAVEPOINT ${savepointName}`, [])
716
+
717
+ // Create a new ORM instance with incremented depth
718
+ const nestedOrm = new ORM(this.db, this.transactionDepth + 1)
719
+
720
+ // Execute the user's transaction function
721
+ const result = await fn(nestedOrm)
722
+
723
+ // Release savepoint on success
724
+ await this.db.execute(`RELEASE SAVEPOINT ${savepointName}`, [])
725
+
726
+ return result
727
+ } catch (error) {
728
+ // Rollback to savepoint on error
729
+ await this.db.execute(`ROLLBACK TO SAVEPOINT ${savepointName}`, [])
730
+ throw error
731
+ }
732
+ } else {
733
+ // Top-level transaction - use the connection's transaction method
734
+ return await this.db.transaction(async (txConnection) => {
735
+ // Create a new ORM instance wrapping the transaction connection
736
+ // Start at depth 1 since we're now inside a transaction
737
+ const txOrm = new ORM(txConnection, 1)
738
+ // Execute the user's transaction function with the transaction ORM
739
+ return await fn(txOrm)
740
+ })
741
+ }
742
+ }
528
743
  }
529
744
 
530
745
  /**