@dockstat/sqlite-wrapper 1.2.7 → 1.3.0

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.
@@ -0,0 +1,184 @@
1
+ import Logger from "@dockstat/logger"
2
+
3
+ /**
4
+ * Centralized logging for sqlite-wrapper
5
+ *
6
+ * This module provides a clean, consistent logging interface
7
+ * for all sqlite-wrapper operations.
8
+ */
9
+
10
+ /**
11
+ * Truncate a string to a maximum length with ellipsis
12
+ */
13
+ function truncate(str: string, maxLength: number): string {
14
+ if (str.length <= maxLength) return str
15
+ return `${str.slice(0, maxLength)}...`
16
+ }
17
+
18
+ /**
19
+ * Format query parameters for logging (truncated for readability)
20
+ */
21
+ function formatParams(params?: unknown[]): string {
22
+ if (!params || params.length === 0) return ""
23
+ const str = JSON.stringify(params)
24
+ return truncate(str, 60)
25
+ }
26
+
27
+ /**
28
+ * SqliteLogger - A wrapper around @dockstat/logger with sqlite-specific helpers
29
+ */
30
+ export class SqliteLogger {
31
+ private logger: Logger
32
+ private tableName?: string
33
+
34
+ constructor(name: string, parent?: Logger, tableName?: string) {
35
+ this.logger = parent ? parent.spawn(name) : new Logger(name)
36
+ this.tableName = tableName
37
+ }
38
+
39
+ /**
40
+ * Create a child logger for a specific component
41
+ */
42
+ child(name: string): SqliteLogger {
43
+ return new SqliteLogger(name, this.logger, this.tableName)
44
+ }
45
+
46
+ /**
47
+ * Create a table-scoped logger
48
+ */
49
+ forTable(tableName: string): SqliteLogger {
50
+ const child = new SqliteLogger(tableName, this.logger, tableName)
51
+ return child
52
+ }
53
+
54
+ // ===== Standard log methods =====
55
+
56
+ debug(message: string): void {
57
+ this.logger.debug(message)
58
+ }
59
+
60
+ info(message: string): void {
61
+ this.logger.info(message)
62
+ }
63
+
64
+ warn(message: string): void {
65
+ this.logger.warn(message)
66
+ }
67
+
68
+ error(message: string): void {
69
+ this.logger.error(message)
70
+ }
71
+
72
+ // ===== Sqlite-specific log helpers =====
73
+
74
+ /**
75
+ * Log a database connection event
76
+ */
77
+ connection(path: string, action: "open" | "close"): void {
78
+ this.logger.info(`Database ${action}: ${path}`)
79
+ }
80
+
81
+ /**
82
+ * Log a SQL query execution
83
+ */
84
+ query(operation: string, sql: string, params?: unknown[]): void {
85
+ const paramStr = formatParams(params)
86
+ const sqlStr = truncate(sql.replace(/\s+/g, " ").trim(), 100)
87
+ const msg = paramStr
88
+ ? `${operation} | ${sqlStr} | params=${paramStr}`
89
+ : `${operation} | ${sqlStr}`
90
+ this.logger.debug(msg)
91
+ }
92
+
93
+ /**
94
+ * Log query results
95
+ */
96
+ result(operation: string, rowCount: number): void {
97
+ this.logger.debug(`${operation} | rows=${rowCount}`)
98
+ }
99
+
100
+ /**
101
+ * Log a table creation
102
+ */
103
+ tableCreate(tableName: string, columns: string[]): void {
104
+ this.logger.debug(`CREATE TABLE ${tableName} | columns=[${columns.join(", ")}]`)
105
+ }
106
+
107
+ /**
108
+ * Log backup operations
109
+ */
110
+ backup(action: "create" | "restore" | "list" | "delete", path?: string): void {
111
+ const msg = path ? `Backup ${action}: ${path}` : `Backup ${action}`
112
+ this.logger.info(msg)
113
+ }
114
+
115
+ /**
116
+ * Log row transformation
117
+ */
118
+ transform(direction: "serialize" | "deserialize", columnTypes: string[]): void {
119
+ if (columnTypes.length === 0) return
120
+ this.logger.debug(`Transform ${direction}: [${columnTypes.join(", ")}]`)
121
+ }
122
+
123
+ /**
124
+ * Log parser configuration
125
+ */
126
+ parserConfig(json: string[], boolean: string[], module: string[]): void {
127
+ const parts: string[] = []
128
+ if (json.length > 0) parts.push(`JSON=[${json.join(",")}]`)
129
+ if (boolean.length > 0) parts.push(`BOOL=[${boolean.join(",")}]`)
130
+ if (module.length > 0) parts.push(`MODULE=[${module.join(",")}]`)
131
+ if (parts.length > 0) {
132
+ this.logger.debug(`Parser config: ${parts.join(" ")}`)
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Log transaction events
138
+ */
139
+ transaction(action: "begin" | "commit" | "rollback" | "savepoint", name?: string): void {
140
+ const msg = name ? `Transaction ${action}: ${name}` : `Transaction ${action}`
141
+ this.logger.debug(msg)
142
+ }
143
+
144
+ /**
145
+ * Get the underlying @dockstat/logger instance
146
+ */
147
+ getBaseLogger(): Logger {
148
+ return this.logger
149
+ }
150
+
151
+ /**
152
+ * Add parent loggers for chaining
153
+ */
154
+ addParents(parents: string[]): void {
155
+ this.logger.addParents(parents)
156
+ }
157
+
158
+ /**
159
+ * Get parents for logger chaining
160
+ */
161
+ getParentsForLoggerChaining(): string[] {
162
+ return this.logger.getParentsForLoggerChaining()
163
+ }
164
+ }
165
+
166
+ // ===== Module exports =====
167
+
168
+ /**
169
+ * Main logger instance for sqlite-wrapper
170
+ */
171
+ export const logger = new SqliteLogger("Sqlite")
172
+
173
+ /**
174
+ * Create a new logger for a specific module
175
+ */
176
+ export function createLogger(name: string): SqliteLogger {
177
+ return logger.child(name)
178
+ }
179
+
180
+ export function addLoggerParents(parents: string[]): void {
181
+ logger.addParents(parents)
182
+ }
183
+
184
+ export default logger
package/utils/sql.ts ADDED
@@ -0,0 +1,241 @@
1
+ import type { SQLQueryBindings } from "bun:sqlite"
2
+
3
+ /**
4
+ * SQL Utilities for sqlite-wrapper
5
+ *
6
+ * Common SQL operations and helpers used across the package.
7
+ */
8
+
9
+ /**
10
+ * Quote a SQL identifier (table name, column name) to prevent injection
11
+ * and handle special characters.
12
+ *
13
+ * @example
14
+ * quoteIdentifier("users") // "users"
15
+ * quoteIdentifier("user name") // "user name"
16
+ * quoteIdentifier('with"quote') // "with""quote"
17
+ */
18
+ export function quoteIdentifier(identifier: string): string {
19
+ return `"${identifier.replace(/"/g, '""')}"`
20
+ }
21
+
22
+ /**
23
+ * Quote a SQL string literal value
24
+ *
25
+ * @example
26
+ * quoteString("hello") // 'hello'
27
+ * quoteString("it's") // 'it''s'
28
+ */
29
+ export function quoteString(value: string): string {
30
+ return `'${value.replace(/'/g, "''")}'`
31
+ }
32
+
33
+ /**
34
+ * Build a comma-separated list of quoted identifiers
35
+ *
36
+ * @example
37
+ * quoteIdentifiers(["id", "name", "email"]) // "id", "name", "email"
38
+ */
39
+ export function quoteIdentifiers(identifiers: string[]): string {
40
+ return identifiers.map(quoteIdentifier).join(", ")
41
+ }
42
+
43
+ /**
44
+ * Build placeholders for parameterized queries
45
+ *
46
+ * @example
47
+ * buildPlaceholders(3) // "?, ?, ?"
48
+ * buildPlaceholders(["a", "b"]) // "?, ?"
49
+ */
50
+ export function buildPlaceholders(countOrArray: number | unknown[]): string {
51
+ const count = typeof countOrArray === "number" ? countOrArray : countOrArray.length
52
+ return Array(count).fill("?").join(", ")
53
+ }
54
+
55
+ /**
56
+ * Build a SET clause for UPDATE statements
57
+ *
58
+ * @example
59
+ * buildSetClause(["name", "email"]) // "name" = ?, "email" = ?
60
+ */
61
+ export function buildSetClause(columns: string[]): string {
62
+ return columns.map((col) => `${quoteIdentifier(col)} = ?`).join(", ")
63
+ }
64
+
65
+ /**
66
+ * Build an INSERT statement
67
+ */
68
+ export function buildInsertSQL(
69
+ table: string,
70
+ columns: string[],
71
+ conflictResolution?: "IGNORE" | "REPLACE" | "ABORT" | "FAIL" | "ROLLBACK"
72
+ ): string {
73
+ const insertType = conflictResolution ? `INSERT OR ${conflictResolution}` : "INSERT"
74
+ const quotedColumns = quoteIdentifiers(columns)
75
+ const placeholders = buildPlaceholders(columns)
76
+
77
+ return `${insertType} INTO ${quoteIdentifier(table)} (${quotedColumns}) VALUES (${placeholders})`
78
+ }
79
+
80
+ /**
81
+ * Build a simple SELECT statement
82
+ */
83
+ export function buildSelectSQL(
84
+ table: string,
85
+ columns: string[] | "*",
86
+ options?: {
87
+ where?: string
88
+ orderBy?: string
89
+ orderDirection?: "ASC" | "DESC"
90
+ limit?: number
91
+ offset?: number
92
+ }
93
+ ): string {
94
+ const cols = columns === "*" ? "*" : quoteIdentifiers(columns)
95
+ let sql = `SELECT ${cols} FROM ${quoteIdentifier(table)}`
96
+
97
+ if (options?.where) {
98
+ sql += ` WHERE ${options.where}`
99
+ }
100
+
101
+ if (options?.orderBy) {
102
+ sql += ` ORDER BY ${quoteIdentifier(options.orderBy)} ${options.orderDirection || "ASC"}`
103
+ }
104
+
105
+ if (options?.limit !== undefined) {
106
+ sql += ` LIMIT ${options.limit}`
107
+ }
108
+
109
+ if (options?.offset !== undefined) {
110
+ sql += ` OFFSET ${options.offset}`
111
+ }
112
+
113
+ return sql
114
+ }
115
+
116
+ /**
117
+ * Build an UPDATE statement
118
+ */
119
+ export function buildUpdateSQL(table: string, columns: string[], where: string): string {
120
+ const setClause = buildSetClause(columns)
121
+ return `UPDATE ${quoteIdentifier(table)} SET ${setClause} WHERE ${where}`
122
+ }
123
+
124
+ /**
125
+ * Build a DELETE statement
126
+ */
127
+ export function buildDeleteSQL(table: string, where: string): string {
128
+ return `DELETE FROM ${quoteIdentifier(table)} WHERE ${where}`
129
+ }
130
+
131
+ /**
132
+ * Check if a string looks like a SQL function call
133
+ *
134
+ * @example
135
+ * isSQLFunction("datetime('now')") // true
136
+ * isSQLFunction("CURRENT_TIMESTAMP") // true
137
+ * isSQLFunction("hello") // false
138
+ */
139
+ export function isSQLFunction(value: string): boolean {
140
+ const functionPatterns = [
141
+ /^\w+\s*\(/i, // function(...)
142
+ /^CURRENT_TIME(STAMP)?$/i,
143
+ /^CURRENT_DATE$/i,
144
+ /^NULL$/i,
145
+ ]
146
+ return functionPatterns.some((pattern) => pattern.test(value.trim()))
147
+ }
148
+
149
+ /**
150
+ * Escape a value for safe inclusion in SQL
151
+ * Returns the SQLite-safe representation
152
+ */
153
+ export function escapeValue(value: SQLQueryBindings): string {
154
+ if (value === null) return "NULL"
155
+ if (typeof value === "number") return String(value)
156
+ if (typeof value === "boolean") return value ? "1" : "0"
157
+ if (typeof value === "string") return quoteString(value)
158
+ if (value instanceof Uint8Array) return `X'${Buffer.from(value).toString("hex")}'`
159
+ return quoteString(String(value))
160
+ }
161
+
162
+ /**
163
+ * Normalize a comparison operator
164
+ */
165
+ export function normalizeOperator(op: string): string {
166
+ const normalized = op.toUpperCase().trim()
167
+ const allowed = ["=", "!=", "<>", "<", "<=", ">", ">=", "LIKE", "GLOB", "IS", "IS NOT"]
168
+
169
+ if (!allowed.includes(normalized)) {
170
+ throw new Error(`Invalid SQL operator: "${op}"`)
171
+ }
172
+
173
+ return normalized
174
+ }
175
+
176
+ /**
177
+ * Build a WHERE condition for a single column
178
+ */
179
+ export function buildCondition(
180
+ column: string,
181
+ operator: string,
182
+ value: SQLQueryBindings | null | undefined
183
+ ): { sql: string; params: SQLQueryBindings[] } {
184
+ const normalizedOp = normalizeOperator(operator)
185
+ const quotedCol = quoteIdentifier(column)
186
+
187
+ // Handle NULL special cases
188
+ if (value === null || value === undefined) {
189
+ if (normalizedOp === "=" || normalizedOp === "IS") {
190
+ return { sql: `${quotedCol} IS NULL`, params: [] }
191
+ }
192
+ if (normalizedOp === "!=" || normalizedOp === "<>" || normalizedOp === "IS NOT") {
193
+ return { sql: `${quotedCol} IS NOT NULL`, params: [] }
194
+ }
195
+ }
196
+
197
+ return {
198
+ sql: `${quotedCol} ${normalizedOp} ?`,
199
+ params: [value as SQLQueryBindings],
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Build an IN clause
205
+ */
206
+ export function buildInClause(
207
+ column: string,
208
+ values: SQLQueryBindings[],
209
+ negate = false
210
+ ): { sql: string; params: SQLQueryBindings[] } {
211
+ if (values.length === 0) {
212
+ throw new Error("IN clause requires at least one value")
213
+ }
214
+
215
+ const quotedCol = quoteIdentifier(column)
216
+ const placeholders = buildPlaceholders(values)
217
+ const keyword = negate ? "NOT IN" : "IN"
218
+
219
+ return {
220
+ sql: `${quotedCol} ${keyword} (${placeholders})`,
221
+ params: values,
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Build a BETWEEN clause
227
+ */
228
+ export function buildBetweenClause(
229
+ column: string,
230
+ min: SQLQueryBindings,
231
+ max: SQLQueryBindings,
232
+ negate = false
233
+ ): { sql: string; params: SQLQueryBindings[] } {
234
+ const quotedCol = quoteIdentifier(column)
235
+ const keyword = negate ? "NOT BETWEEN" : "BETWEEN"
236
+
237
+ return {
238
+ sql: `${quotedCol} ${keyword} ? AND ?`,
239
+ params: [min, max],
240
+ }
241
+ }
@@ -0,0 +1,256 @@
1
+ import type { SQLQueryBindings } from "bun:sqlite"
2
+ import type { Parser } from "../types"
3
+ import { createLogger } from "./logger"
4
+
5
+ /**
6
+ * Row Transformer for sqlite-wrapper
7
+ *
8
+ * Handles serialization (to DB) and deserialization (from DB) of row data,
9
+ * including JSON columns, Boolean columns, and Module columns.
10
+ */
11
+
12
+ const logger = createLogger("transformer")
13
+
14
+ /**
15
+ * Generic row data type
16
+ */
17
+ export type RowData = Record<string, SQLQueryBindings>
18
+
19
+ /**
20
+ * Transform options
21
+ */
22
+ export interface TransformOptions<T> {
23
+ parser?: Parser<T>
24
+ }
25
+
26
+ /**
27
+ * Transform a row FROM the database (deserialization)
28
+ *
29
+ * - JSON columns: Parse JSON strings back to objects
30
+ * - BOOLEAN columns: Convert 0/1 to true/false
31
+ * - MODULE columns: Transpile and create importable URLs
32
+ */
33
+ export function transformFromDb<T extends Record<string, unknown>>(
34
+ row: unknown,
35
+ options?: TransformOptions<T>
36
+ ): T {
37
+ if (!row || typeof row !== "object") {
38
+ return row as T
39
+ }
40
+
41
+ const parser = options?.parser
42
+ if (!parser) {
43
+ return row as T
44
+ }
45
+
46
+ const transformed = { ...row } as RowData
47
+ const transformedColumns: string[] = []
48
+
49
+ // Transform JSON columns
50
+ if (parser.JSON && parser.JSON.length > 0) {
51
+ for (const column of parser.JSON) {
52
+ const columnKey = String(column)
53
+ const value = transformed[columnKey]
54
+
55
+ if (value !== null && value !== undefined && typeof value === "string") {
56
+ try {
57
+ transformed[columnKey] = JSON.parse(value)
58
+ transformedColumns.push(`JSON:${columnKey}`)
59
+ } catch {
60
+ // Keep original value if JSON parsing fails
61
+ logger.warn(`Failed to parse JSON column: ${columnKey}`)
62
+ }
63
+ }
64
+ }
65
+ }
66
+
67
+ // Transform BOOLEAN columns
68
+ if (parser.BOOLEAN && parser.BOOLEAN.length > 0) {
69
+ for (const column of parser.BOOLEAN) {
70
+ const columnKey = String(column)
71
+ const value = transformed[columnKey]
72
+
73
+ if (value === null || value === undefined) {
74
+ continue
75
+ }
76
+
77
+ // Already a boolean - no transformation needed
78
+ if (typeof value === "boolean") {
79
+ continue
80
+ }
81
+
82
+ // Convert number (0/1) to boolean
83
+ if (typeof value === "number") {
84
+ transformed[columnKey] = value === 1
85
+ transformedColumns.push(`BOOL:${columnKey}`)
86
+ continue
87
+ }
88
+
89
+ // Convert string representations
90
+ if (typeof value === "string") {
91
+ const normalized = value.trim().toLowerCase()
92
+ if (["1", "true", "t", "yes"].includes(normalized)) {
93
+ transformed[columnKey] = true
94
+ transformedColumns.push(`BOOL:${columnKey}`)
95
+ } else if (["0", "false", "f", "no"].includes(normalized)) {
96
+ transformed[columnKey] = false
97
+ transformedColumns.push(`BOOL:${columnKey}`)
98
+ } else {
99
+ // Try numeric conversion
100
+ const num = Number(normalized)
101
+ if (!Number.isNaN(num)) {
102
+ transformed[columnKey] = num === 1
103
+ transformedColumns.push(`BOOL:${columnKey}`)
104
+ }
105
+ }
106
+ }
107
+ }
108
+ }
109
+
110
+ // Transform MODULE columns
111
+ if (parser.MODULE && Object.keys(parser.MODULE).length > 0) {
112
+ for (const [funcKey, options] of Object.entries(parser.MODULE)) {
113
+ const value = transformed[funcKey]
114
+
115
+ if (value !== undefined && value !== null && typeof value === "string") {
116
+ try {
117
+ const transpiler = new Bun.Transpiler(options)
118
+ const compiled = transpiler.transformSync(value)
119
+ const blob = new Blob([compiled], { type: "text/javascript" })
120
+ transformed[funcKey] = URL.createObjectURL(blob)
121
+ transformedColumns.push(`MODULE:${funcKey}`)
122
+ } catch (_error) {
123
+ logger.warn(`Failed to transpile MODULE column: ${funcKey}`)
124
+ }
125
+ }
126
+ }
127
+ }
128
+
129
+ if (transformedColumns.length > 0) {
130
+ logger.transform("deserialize", transformedColumns)
131
+ }
132
+
133
+ return transformed as T
134
+ }
135
+
136
+ /**
137
+ * Transform multiple rows FROM the database
138
+ */
139
+ export function transformRowsFromDb<T extends Record<string, unknown>>(
140
+ rows: unknown[],
141
+ options?: TransformOptions<T>
142
+ ): T[] {
143
+ if (!rows || !Array.isArray(rows)) {
144
+ return []
145
+ }
146
+
147
+ return rows.map((row) => transformFromDb<T>(row, options))
148
+ }
149
+
150
+ /**
151
+ * Transform a row TO the database (serialization)
152
+ *
153
+ * - JSON columns: Stringify objects to JSON strings
154
+ * - MODULE columns: Stringify functions
155
+ */
156
+ export function transformToDb<T extends Record<string, unknown>>(
157
+ row: Partial<T>,
158
+ options?: TransformOptions<T>
159
+ ): RowData {
160
+ if (!row || typeof row !== "object") {
161
+ return row as RowData
162
+ }
163
+
164
+ const parser = options?.parser
165
+ if (!parser) {
166
+ return row as RowData
167
+ }
168
+
169
+ const transformed = { ...row } as RowData
170
+ const transformedColumns: string[] = []
171
+
172
+ // Serialize JSON columns
173
+ if (parser.JSON && parser.JSON.length > 0) {
174
+ for (const column of parser.JSON) {
175
+ const columnKey = String(column)
176
+ const value = transformed[columnKey]
177
+
178
+ if (value !== undefined && value !== null && typeof value === "object") {
179
+ transformed[columnKey] = JSON.stringify(value)
180
+ transformedColumns.push(`JSON:${columnKey}`)
181
+ }
182
+ }
183
+ }
184
+
185
+ // Serialize MODULE columns (functions to strings)
186
+ if (parser.MODULE && Object.keys(parser.MODULE).length > 0) {
187
+ for (const [funcKey, options] of Object.entries(parser.MODULE)) {
188
+ const value = transformed[funcKey]
189
+
190
+ if (value !== undefined && value !== null && typeof value === "function") {
191
+ try {
192
+ const transpiler = new Bun.Transpiler(options)
193
+ const fnValue = value as () => unknown
194
+ transformed[funcKey] = transpiler.transformSync(fnValue.toString())
195
+ transformedColumns.push(`MODULE:${funcKey}`)
196
+ } catch {
197
+ logger.warn(`Failed to serialize MODULE column: ${funcKey}`)
198
+ }
199
+ }
200
+ }
201
+ }
202
+
203
+ if (transformedColumns.length > 0) {
204
+ logger.transform("serialize", transformedColumns)
205
+ }
206
+
207
+ return transformed
208
+ }
209
+
210
+ /**
211
+ * Transform multiple rows TO the database
212
+ */
213
+ export function transformRowsToDb<T extends Record<string, unknown>>(
214
+ rows: Partial<T>[],
215
+ options?: TransformOptions<T>
216
+ ): RowData[] {
217
+ if (!rows || !Array.isArray(rows)) {
218
+ return []
219
+ }
220
+
221
+ return rows.map((row) => transformToDb<T>(row, options))
222
+ }
223
+
224
+ /**
225
+ * Check if a parser has any transformations configured
226
+ */
227
+ export function hasTransformations<T>(parser?: Parser<T>): boolean {
228
+ if (!parser) return false
229
+
230
+ const hasJson = !!(parser.JSON && parser.JSON.length > 0)
231
+ const hasBoolean = !!(parser.BOOLEAN && parser.BOOLEAN.length > 0)
232
+ const hasModule = !!(parser.MODULE && Object.keys(parser.MODULE).length > 0)
233
+
234
+ return hasJson || hasBoolean || hasModule
235
+ }
236
+
237
+ /**
238
+ * Get a summary of parser configuration
239
+ */
240
+ export function getParserSummary<T>(parser?: Parser<T>): string {
241
+ if (!parser) return "none"
242
+
243
+ const parts: string[] = []
244
+
245
+ if (parser.JSON && parser.JSON.length > 0) {
246
+ parts.push(`JSON(${parser.JSON.length})`)
247
+ }
248
+ if (parser.BOOLEAN && parser.BOOLEAN.length > 0) {
249
+ parts.push(`BOOL(${parser.BOOLEAN.length})`)
250
+ }
251
+ if (parser.MODULE && Object.keys(parser.MODULE).length > 0) {
252
+ parts.push(`MODULE(${Object.keys(parser.MODULE).length})`)
253
+ }
254
+
255
+ return parts.length > 0 ? parts.join(", ") : "none"
256
+ }