@pineliner/odb-client 1.0.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,200 @@
1
+ import type { ODBLiteConfig, ODBLiteResponse, QueryResult } from '../types.ts'
2
+ import { ConnectionError, QueryError } from '../types.ts'
3
+
4
+ /**
5
+ * HTTP client for communicating with ODBLite service
6
+ */
7
+ export class HTTPClient {
8
+ private config: ODBLiteConfig
9
+
10
+ constructor(config: ODBLiteConfig) {
11
+ this.config = {
12
+ timeout: 30000,
13
+ retries: 3,
14
+ ...config
15
+ }
16
+
17
+ // Ensure baseUrl doesn't end with slash
18
+ if (typeof this.config.baseUrl === 'string') {
19
+ this.config.baseUrl = this.config.baseUrl.replace(/\/$/, '')
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Execute a query against the ODBLite service
25
+ */
26
+ async query<T = any>(sql: string, params: any[] = []): Promise<QueryResult<T>> {
27
+ if (!this.config.databaseId) {
28
+ throw new ConnectionError('No database ID configured. Use setDatabase() first.')
29
+ }
30
+
31
+ const url = `${this.config.baseUrl}/query/${this.config.databaseId}`
32
+ const body = {
33
+ sql,
34
+ params
35
+ }
36
+
37
+ try {
38
+ const response = await this.makeRequest(url, {
39
+ method: 'POST',
40
+ headers: {
41
+ 'Content-Type': 'application/json',
42
+ Authorization: `Bearer ${this.config.apiKey}`
43
+ },
44
+ body: JSON.stringify(body)
45
+ })
46
+
47
+ const data: ODBLiteResponse<T> = await response.json()
48
+
49
+ if (!data.success) {
50
+ throw new QueryError(data.error || 'Query failed', sql, params)
51
+ }
52
+
53
+ // Handle nested data structure: { success: true, data: { rows: [...] } }
54
+ let rows: T[] = []
55
+ if (data.data && typeof data.data === 'object' && 'rows' in data.data) {
56
+ rows = (data.data as any).rows
57
+ } else if (Array.isArray(data.data)) {
58
+ rows = data.data
59
+ } else if (Array.isArray(data.rows)) {
60
+ rows = data.rows
61
+ }
62
+
63
+ return {
64
+ rows: rows,
65
+ rowsAffected: data.rowsAffected || (data.data as any)?.rowsAffected || 0,
66
+ executionTime: data.executionTime || (data.data as any)?.executionTime || 0,
67
+ databaseName: data.databaseName || (data.data as any)?.databaseName
68
+ }
69
+ } catch (error) {
70
+ if (error instanceof QueryError || error instanceof ConnectionError) {
71
+ throw error
72
+ }
73
+
74
+ throw new QueryError(error instanceof Error ? error.message : 'Unknown error occurred', sql, params, error instanceof Error ? error : undefined)
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Check database health
80
+ */
81
+ async ping(): Promise<boolean> {
82
+ if (!this.config.databaseId) {
83
+ return false
84
+ }
85
+
86
+ try {
87
+ const url = `${this.config.baseUrl}/api/db/${this.config.databaseId}/health`
88
+ const response = await this.makeRequest(url, {
89
+ method: 'GET',
90
+ headers: {
91
+ Authorization: `Bearer ${this.config.apiKey}`
92
+ }
93
+ })
94
+
95
+ const data = await response.json()
96
+ return data.status === 'healthy'
97
+ } catch {
98
+ return false
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Get database information
104
+ */
105
+ async getDatabaseInfo(): Promise<any> {
106
+ if (!this.config.databaseId) {
107
+ throw new ConnectionError('No database ID configured')
108
+ }
109
+
110
+ const url = `${this.config.baseUrl}/query/${this.config.databaseId}`
111
+ const response = await this.makeRequest(url, {
112
+ method: 'GET',
113
+ headers: {
114
+ Authorization: `Bearer ${this.config.apiKey}`
115
+ }
116
+ })
117
+
118
+ const data = await response.json()
119
+ if (!data.success) {
120
+ throw new ConnectionError(data.error || 'Failed to get database info')
121
+ }
122
+
123
+ return data
124
+ }
125
+
126
+ /**
127
+ * Set the database ID for subsequent queries
128
+ */
129
+ setDatabase(databaseId: string): void {
130
+ this.config.databaseId = databaseId
131
+ }
132
+
133
+ /**
134
+ * Make HTTP request with retry logic
135
+ */
136
+ private async makeRequest(url: string, options: RequestInit): Promise<Response> {
137
+ let lastError: Error | undefined
138
+
139
+ for (let attempt = 1; attempt <= (this.config.retries || 3); attempt++) {
140
+ try {
141
+ const controller = new AbortController()
142
+ const timeoutId = setTimeout(() => controller.abort(), this.config.timeout)
143
+
144
+ const response = await fetch(url, {
145
+ ...options,
146
+ signal: controller.signal
147
+ })
148
+
149
+ clearTimeout(timeoutId)
150
+
151
+ if (!response.ok) {
152
+ const errorText = await response.text()
153
+ let errorData
154
+
155
+ try {
156
+ errorData = JSON.parse(errorText)
157
+ } catch {
158
+ errorData = { error: errorText }
159
+ }
160
+
161
+ throw new ConnectionError(`HTTP ${response.status}: ${errorData.error || response.statusText}`)
162
+ }
163
+
164
+ return response
165
+ } catch (error) {
166
+ lastError = error instanceof Error ? error : new Error('Unknown error')
167
+
168
+ // Don't retry on client errors (4xx)
169
+ if (error instanceof ConnectionError && error.message.includes('HTTP 4')) {
170
+ throw error
171
+ }
172
+
173
+ // Wait before retry (exponential backoff)
174
+ if (attempt < (this.config.retries || 3)) {
175
+ const delay = Math.min(1000 * 2 ** (attempt - 1), 10000)
176
+ await new Promise((resolve) => setTimeout(resolve, delay))
177
+ }
178
+ }
179
+ }
180
+
181
+ throw new ConnectionError(`Failed after ${this.config.retries} attempts: ${lastError?.message}`, lastError)
182
+ }
183
+
184
+ /**
185
+ * Create a new HTTPClient with updated config
186
+ */
187
+ configure(updates: Partial<ODBLiteConfig>): HTTPClient {
188
+ return new HTTPClient({
189
+ ...this.config,
190
+ ...updates
191
+ })
192
+ }
193
+
194
+ /**
195
+ * Get current configuration
196
+ */
197
+ getConfig(): Readonly<ODBLiteConfig> {
198
+ return { ...this.config }
199
+ }
200
+ }
@@ -0,0 +1,330 @@
1
+ import type { SQLFragment, PreparedQuery, QueryParameter } from '../types.ts';
2
+
3
+ /**
4
+ * SQL template string parser that converts postgres.js-style template strings
5
+ * into LibSQL-compatible parameterized queries
6
+ */
7
+ export class SQLParser {
8
+ /**
9
+ * Parse input that can be template strings, objects, or arrays
10
+ * Context-aware like postgres.js
11
+ */
12
+ static parse(sql: TemplateStringsArray | any, values?: any[]): PreparedQuery {
13
+ // Handle template string arrays (have the raw property or look like template strings)
14
+ if (Array.isArray(sql) && (('raw' in sql) || (typeof sql[0] === 'string' && values !== undefined))) {
15
+ return this.parseTemplateString(sql as unknown as TemplateStringsArray, values || []);
16
+ }
17
+
18
+ // Handle direct object/array inputs (context detection)
19
+ return this.parseContextualInput(sql, values);
20
+ }
21
+
22
+ /**
23
+ * Parse contextual input (objects, arrays, etc.)
24
+ * Uses heuristics to detect the intended context
25
+ */
26
+ private static parseContextualInput(input: any, values?: any[], context?: string): PreparedQuery {
27
+ if (Array.isArray(input)) {
28
+ // Check if this is an array of objects (for INSERT VALUES)
29
+ if (input.length > 0 && input[0] && typeof input[0] === 'object' && input[0].constructor === Object) {
30
+ const insertResult = this.insertValues(input);
31
+ return {
32
+ sql: insertResult.text,
33
+ params: insertResult.values
34
+ };
35
+ }
36
+
37
+ // Array of primitives - assume it's for IN clause
38
+ const placeholders = input.map(() => '?').join(', ');
39
+ return {
40
+ sql: `(${placeholders})`,
41
+ params: input.map(v => this.convertValue(v))
42
+ };
43
+ }
44
+
45
+ if (input && typeof input === 'object' && input.constructor === Object) {
46
+ // Plain object - try to detect context based on structure and usage patterns
47
+ const entries = Object.entries(input).filter(([, value]) => value !== undefined);
48
+
49
+ if (entries.length === 0) {
50
+ return { sql: '', params: [] };
51
+ }
52
+
53
+ // Analyze the object structure to determine intent
54
+ const hasNullValues = entries.some(([, value]) => value === null);
55
+ const hasArrayValues = entries.some(([, value]) => Array.isArray(value));
56
+ const hasComplexValues = entries.some(([, value]) =>
57
+ value !== null &&
58
+ typeof value === 'object' &&
59
+ !Array.isArray(value)
60
+ );
61
+
62
+ // Strong indicators for WHERE clauses:
63
+ // - null values (for IS NULL checks)
64
+ // - array values (for IN clauses)
65
+ // - complex nested objects
66
+ if (hasNullValues || hasArrayValues || hasComplexValues) {
67
+ const whereResult = this.where(input);
68
+ return {
69
+ sql: whereResult.text,
70
+ params: whereResult.values
71
+ };
72
+ }
73
+
74
+ // For simple objects with primitive values, we need to guess context
75
+ // This is inherently ambiguous - could be SET or VALUES
76
+ // We'll default to a flexible format that works for both
77
+
78
+ // If single object, more likely to be SET clause
79
+ if (entries.length <= 3 && !Array.isArray(input)) {
80
+ // Return SET format by default
81
+ return this.buildSetClause(input);
82
+ }
83
+
84
+ // For larger objects or arrays of objects, likely INSERT VALUES
85
+ const insertResult = this.insertValues(input);
86
+ return {
87
+ sql: insertResult.text,
88
+ params: insertResult.values
89
+ };
90
+ }
91
+
92
+ // Fallback for other types
93
+ return {
94
+ sql: '?',
95
+ params: [this.convertValue(input)]
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Build a SET clause for UPDATE statements
101
+ */
102
+ private static buildSetClause(data: Record<string, any>): PreparedQuery {
103
+ const entries = Object.entries(data).filter(([, value]) => value !== undefined);
104
+
105
+ if (entries.length === 0) {
106
+ return { sql: '', params: [] };
107
+ }
108
+
109
+ const setClauses = entries.map(([key]) => `${this.escapeIdentifier(key)} = ?`).join(', ');
110
+ const values = entries.map(([, value]) => this.convertValue(value));
111
+
112
+ return {
113
+ sql: setClauses,
114
+ params: values
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Parse template string SQL into LibSQL format
120
+ */
121
+ private static parseTemplateString(sql: TemplateStringsArray, values: any[]): PreparedQuery {
122
+ const fragments: string[] = [...sql];
123
+ const params: any[] = [];
124
+
125
+ let query = '';
126
+ let paramIndex = 1;
127
+
128
+ for (let i = 0; i < fragments.length; i++) {
129
+ query += fragments[i];
130
+
131
+ if (i < values.length) {
132
+ const value = values[i];
133
+
134
+ // Handle different value types using context detection
135
+ if (Array.isArray(value)) {
136
+ // Handle IN clauses: sql`SELECT * FROM users WHERE id IN ${[1, 2, 3]}`
137
+ const placeholders = value.map(() => `?`).join(', ');
138
+ query += `(${placeholders})`;
139
+ params.push(...value.map(v => this.convertValue(v)));
140
+ } else if (value && typeof value === 'object' && value.constructor === Object) {
141
+ // Use context detection for objects
142
+ const contextResult = this.parseContextualInput(value);
143
+ query += contextResult.sql;
144
+ params.push(...contextResult.params);
145
+ } else if (typeof value === 'string' && value.startsWith('__RAW__')) {
146
+ // Handle raw SQL: sql`SELECT * FROM ${raw('users')}`
147
+ query += value.slice(7); // Remove __RAW__ prefix
148
+ } else {
149
+ // Regular parameter
150
+ query += '?';
151
+ params.push(this.convertValue(value));
152
+ }
153
+ }
154
+ }
155
+
156
+ return {
157
+ sql: query.trim(),
158
+ params
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Create a raw SQL fragment (not parameterized)
164
+ */
165
+ static raw(text: string): string {
166
+ return `__RAW__${text}`;
167
+ }
168
+
169
+ /**
170
+ * Create an identifier (table name, column name, etc.)
171
+ */
172
+ static identifier(name: string): string {
173
+ return this.escapeIdentifier(name);
174
+ }
175
+
176
+ /**
177
+ * Escape SQL identifiers (table names, column names)
178
+ */
179
+ private static escapeIdentifier(identifier: string): string {
180
+ // SQLite uses double quotes for identifiers
181
+ return `"${identifier.replace(/"/g, '""')}"`;
182
+ }
183
+
184
+ /**
185
+ * Convert JavaScript values to LibSQL-compatible values
186
+ */
187
+ private static convertValue(value: any): any {
188
+ if (value === null || value === undefined) {
189
+ return null;
190
+ }
191
+
192
+ if (value instanceof Date) {
193
+ // Convert Date to ISO string for SQLite
194
+ return value.toISOString();
195
+ }
196
+
197
+ if (value instanceof Buffer) {
198
+ // Convert Buffer to Uint8Array for LibSQL
199
+ return new Uint8Array(value);
200
+ }
201
+
202
+ if (typeof value === 'boolean') {
203
+ // SQLite uses 0/1 for booleans
204
+ return value ? 1 : 0;
205
+ }
206
+
207
+ if (typeof value === 'bigint') {
208
+ // Convert BigInt to string to avoid precision loss
209
+ return value.toString();
210
+ }
211
+
212
+ if (typeof value === 'object') {
213
+ // Serialize objects as JSON
214
+ return JSON.stringify(value);
215
+ }
216
+
217
+ return value;
218
+ }
219
+
220
+ /**
221
+ * Create a SQL fragment for building complex queries
222
+ */
223
+ static fragment(sql: TemplateStringsArray, ...values: any[]): SQLFragment {
224
+ const parsed = this.parse(sql, values);
225
+ return {
226
+ text: parsed.sql,
227
+ values: parsed.params
228
+ };
229
+ }
230
+
231
+ /**
232
+ * Join multiple SQL fragments
233
+ */
234
+ static join(fragments: SQLFragment[], separator = ' '): SQLFragment {
235
+ const text = fragments.map(f => f.text).join(separator);
236
+ const values = fragments.flatMap(f => f.values);
237
+
238
+ return { text, values };
239
+ }
240
+
241
+ /**
242
+ * Helper for building WHERE clauses from objects
243
+ */
244
+ static where(conditions: Record<string, any>): SQLFragment {
245
+ const entries = Object.entries(conditions).filter(([, value]) => value !== undefined);
246
+
247
+ if (entries.length === 0) {
248
+ return { text: '', values: [] };
249
+ }
250
+
251
+ const clauses = entries.map(([key, value]) => {
252
+ if (value === null) {
253
+ return `${this.escapeIdentifier(key)} IS NULL`;
254
+ }
255
+ if (Array.isArray(value)) {
256
+ const placeholders = value.map(() => '?').join(', ');
257
+ return `${this.escapeIdentifier(key)} IN (${placeholders})`;
258
+ }
259
+ return `${this.escapeIdentifier(key)} = ?`;
260
+ });
261
+
262
+ const values = entries.flatMap(([, value]) => {
263
+ if (value === null) return [];
264
+ if (Array.isArray(value)) return value.map(v => this.convertValue(v));
265
+ return [this.convertValue(value)];
266
+ });
267
+
268
+ return {
269
+ text: clauses.join(' AND '),
270
+ values
271
+ };
272
+ }
273
+
274
+ /**
275
+ * Helper for building INSERT VALUES from objects
276
+ */
277
+ static insertValues(data: Record<string, any> | Record<string, any>[]): SQLFragment {
278
+ const records = Array.isArray(data) ? data : [data];
279
+
280
+ if (records.length === 0) {
281
+ throw new Error('No data provided for insert');
282
+ }
283
+
284
+ const keys = Object.keys(records[0]);
285
+ const columns = keys.map(key => this.escapeIdentifier(key)).join(', ');
286
+
287
+ const valueClauses = records.map(record => {
288
+ const placeholders = keys.map(() => '?').join(', ');
289
+ return `(${placeholders})`;
290
+ }).join(', ');
291
+
292
+ const values = records.flatMap(record =>
293
+ keys.map(key => this.convertValue(record[key]))
294
+ );
295
+
296
+ return {
297
+ text: `(${columns}) VALUES ${valueClauses}`,
298
+ values
299
+ };
300
+ }
301
+
302
+ /**
303
+ * Helper for building UPDATE SET clauses from objects
304
+ */
305
+ static updateSet(data: Record<string, any>): SQLFragment {
306
+ const entries = Object.entries(data).filter(([, value]) => value !== undefined);
307
+
308
+ if (entries.length === 0) {
309
+ throw new Error('No data provided for update');
310
+ }
311
+
312
+ const setClauses = entries.map(([key]) => `${this.escapeIdentifier(key)} = ?`).join(', ');
313
+ const values = entries.map(([, value]) => this.convertValue(value));
314
+
315
+ return {
316
+ text: setClauses,
317
+ values
318
+ };
319
+ }
320
+ }
321
+
322
+ // Export convenience functions
323
+ export const sql = SQLParser.parse.bind(SQLParser);
324
+ export const raw = SQLParser.raw.bind(SQLParser);
325
+ export const identifier = SQLParser.identifier.bind(SQLParser);
326
+ export const fragment = SQLParser.fragment.bind(SQLParser);
327
+ export const join = SQLParser.join.bind(SQLParser);
328
+ export const where = SQLParser.where.bind(SQLParser);
329
+ export const insertValues = SQLParser.insertValues.bind(SQLParser);
330
+ export const updateSet = SQLParser.updateSet.bind(SQLParser);