@pineliner/odb-client 1.0.0 → 1.0.2

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,336 @@
1
+ import type {
2
+ DatabaseManagerConfig,
3
+ BackendType,
4
+ Connection,
5
+ DatabaseAdapter,
6
+ MigrationConfig,
7
+ QueryResult,
8
+ } from './types'
9
+ import { BunSQLiteAdapter } from './adapters/bun-sqlite'
10
+ import { LibSQLAdapter } from './adapters/libsql'
11
+ import { ODBLiteAdapter } from './adapters/odblite'
12
+ import { parseSQL } from './sql-parser'
13
+ import fs from 'node:fs'
14
+
15
+ /**
16
+ * Database factory for creating and managing multiple databases
17
+ *
18
+ * Features:
19
+ * - Creates multiple databases from single manager instance
20
+ * - Supports libsql, bun:sqlite, and ODB-Lite backends
21
+ * - Connection pooling with LRU eviction
22
+ * - Automatic schema migrations
23
+ *
24
+ * @example
25
+ * ```ts
26
+ * const dbManager = new DatabaseManager({
27
+ * backend: 'libsql',
28
+ * databasePath: './data'
29
+ * })
30
+ *
31
+ * // Create databases
32
+ * await dbManager.createDatabase('identity', { schemaContent })
33
+ * await dbManager.createDatabase('analytics', { schemaContent })
34
+ *
35
+ * // Get connections
36
+ * const identityDB = await dbManager.getConnection('identity')
37
+ * const analyticsDB = await dbManager.getConnection('analytics')
38
+ * ```
39
+ */
40
+ export class DatabaseManager {
41
+ private config: DatabaseManagerConfig
42
+ private adapter: DatabaseAdapter
43
+ private connections: Map<string, Connection> = new Map()
44
+
45
+ // Connection pool management (LRU)
46
+ private connectionTimestamps: Map<string, number> = new Map()
47
+ private maxConnections: number
48
+
49
+ constructor(config: DatabaseManagerConfig) {
50
+ this.config = config
51
+ this.maxConnections = config.connectionPoolSize || 10
52
+
53
+ // Create appropriate adapter based on backend
54
+ switch (config.backend) {
55
+ case 'bun-sqlite':
56
+ this.adapter = new BunSQLiteAdapter({ databasePath: config.databasePath, create: true })
57
+ break
58
+
59
+ case 'libsql':
60
+ this.adapter = new LibSQLAdapter({
61
+ url: `file:${config.databasePath}`,
62
+ ...config.libsql
63
+ })
64
+ break
65
+
66
+ case 'odblite':
67
+ if (!config.odblite) {
68
+ throw new Error('odblite config required for odblite backend')
69
+ }
70
+ this.adapter = new ODBLiteAdapter(config.odblite)
71
+ break
72
+
73
+ default:
74
+ throw new Error(`Unknown backend type: ${config.backend}`)
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Create a new database
80
+ * @param name - Database name (becomes filename or ODB-Lite database name)
81
+ * @param options - Optional creation options
82
+ */
83
+ async createDatabase(
84
+ name: string,
85
+ options?: { schemaContent?: string }
86
+ ): Promise<void> {
87
+ console.log(`📦 Creating database: ${name}`)
88
+
89
+ // Build database-specific config
90
+ const dbConfig = this.getDatabaseConfig(name)
91
+
92
+ // Create connection
93
+ const conn = await this.adapter.connect(dbConfig)
94
+
95
+ // Run migrations if schema provided
96
+ if (options?.schemaContent) {
97
+ await this.runMigrations(conn, { schemaContent: options.schemaContent })
98
+ }
99
+
100
+ // Store connection
101
+ this.connections.set(name, conn)
102
+ this.connectionTimestamps.set(name, Date.now())
103
+
104
+ // Evict LRU if pool is full
105
+ if (this.connections.size > this.maxConnections) {
106
+ await this.evictLRU()
107
+ }
108
+
109
+ console.log(`✅ Database created: ${name}`)
110
+ }
111
+
112
+ /**
113
+ * Check if a database exists
114
+ * @param name - Database name
115
+ * @returns true if database exists, false otherwise
116
+ *
117
+ * Note: For local backends (libsql, bun-sqlite), checks file existence.
118
+ * For ODB-Lite, checks if database is in connection cache.
119
+ */
120
+ async databaseExists(name: string): Promise<boolean> {
121
+ // Check if already in connection cache
122
+ if (this.connections.has(name)) {
123
+ return true
124
+ }
125
+
126
+ // For local file-based backends, check if file exists
127
+ switch (this.config.backend) {
128
+ case 'bun-sqlite': {
129
+ const dbPath = `${this.config.databasePath}/${name}.db`
130
+ return fs.existsSync(dbPath)
131
+ }
132
+
133
+ case 'libsql': {
134
+ const dbPath = `${this.config.databasePath}/${name}.db`
135
+ return fs.existsSync(dbPath)
136
+ }
137
+
138
+ case 'odblite': {
139
+ // For ODB-Lite, only return true if in cache
140
+ // (no way to check remote database existence without connecting)
141
+ return false
142
+ }
143
+
144
+ default:
145
+ return false
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Get connection to a database
151
+ * @param name - Database name
152
+ *
153
+ * If database exists on disk but not in cache, it will be connected automatically.
154
+ * If database doesn't exist at all, an error will be thrown.
155
+ */
156
+ async getConnection(name: string): Promise<Connection> {
157
+ // Check if connection exists in cache
158
+ let conn = this.connections.get(name)
159
+
160
+ if (conn) {
161
+ this.connectionTimestamps.set(name, Date.now())
162
+ return conn
163
+ }
164
+
165
+ // Check if database exists on disk
166
+ const exists = await this.databaseExists(name)
167
+ if (exists) {
168
+ // Connect to existing database
169
+ const dbConfig = this.getDatabaseConfig(name)
170
+ conn = await this.adapter.connect(dbConfig)
171
+
172
+ // Store connection
173
+ this.connections.set(name, conn)
174
+ this.connectionTimestamps.set(name, Date.now())
175
+
176
+ // Evict LRU if pool is full
177
+ if (this.connections.size > this.maxConnections) {
178
+ await this.evictLRU()
179
+ }
180
+
181
+ return conn
182
+ }
183
+
184
+ // If database doesn't exist, throw error
185
+ throw new Error(
186
+ `Database "${name}" not found. Call createDatabase("${name}", { schemaContent }) first.`
187
+ )
188
+ }
189
+
190
+ /**
191
+ * Delete a database
192
+ * @param name - Database name
193
+ */
194
+ async deleteDatabase(name: string): Promise<void> {
195
+ console.log(`🗑️ Deleting database: ${name}`)
196
+
197
+ // Close connection if exists
198
+ const conn = this.connections.get(name)
199
+ if (conn) {
200
+ await conn.close()
201
+ this.connections.delete(name)
202
+ this.connectionTimestamps.delete(name)
203
+ }
204
+
205
+ // Backend-specific deletion
206
+ await this.adapter.disconnect(name)
207
+
208
+ console.log(`✅ Database deleted: ${name}`)
209
+ }
210
+
211
+ /**
212
+ * Execute SQL content on a database connection
213
+ * Useful for running migrations or initial schema
214
+ * @param name - Database name
215
+ * @param sqlContent - SQL content to execute
216
+ */
217
+ async executeSQLFile(name: string, sqlContent: string): Promise<void> {
218
+ const conn = await this.getConnection(name)
219
+ await this.runMigrations(conn, { schemaContent: sqlContent })
220
+ }
221
+
222
+ /**
223
+ * List all managed databases
224
+ */
225
+ async listDatabases(): Promise<string[]> {
226
+ return Array.from(this.connections.keys())
227
+ }
228
+
229
+ /**
230
+ * Close all connections
231
+ */
232
+ async close(): Promise<void> {
233
+ console.log(`🔌 Closing all database connections...`)
234
+
235
+ for (const [name, conn] of this.connections) {
236
+ await conn.close()
237
+ }
238
+
239
+ this.connections.clear()
240
+ this.connectionTimestamps.clear()
241
+
242
+ console.log(`✅ All connections closed`)
243
+ }
244
+
245
+ /**
246
+ * Run migrations on a connection
247
+ */
248
+ private async runMigrations(conn: Connection, config: MigrationConfig): Promise<void> {
249
+ // Parse SQL statements
250
+ const { pragmaStatements, regularStatements } = parseSQL(config.schemaContent)
251
+
252
+ // Execute PRAGMA statements first
253
+ for (const pragma of pragmaStatements) {
254
+ try {
255
+ await conn.execute(pragma)
256
+ } catch (error: any) {
257
+ // Ignore PRAGMA errors (they might not be supported on all backends)
258
+ console.log(`⚠️ PRAGMA note: ${error.message}`)
259
+ }
260
+ }
261
+
262
+ // Execute regular statements
263
+ for (const statement of regularStatements) {
264
+ try {
265
+ await conn.execute(statement)
266
+ } catch (error: any) {
267
+ // Ignore "already exists" errors for idempotency
268
+ if (
269
+ error.message?.includes('already exists') ||
270
+ error.message?.includes('no such column') ||
271
+ error.message?.includes('no such table')
272
+ ) {
273
+ console.log(` ⚠️ Skipping: ${error.message}`)
274
+ continue
275
+ }
276
+ console.error(`❌ Migration failed: ${statement.substring(0, 100)}...`)
277
+ throw error
278
+ }
279
+ }
280
+
281
+ console.log(`✅ Migrations completed (${regularStatements.length} statements)`)
282
+ }
283
+
284
+ /**
285
+ * Get database-specific configuration
286
+ */
287
+ private getDatabaseConfig(name: string): any {
288
+ switch (this.config.backend) {
289
+ case 'bun-sqlite':
290
+ return {
291
+ databasePath: `${this.config.databasePath}/${name}.db`,
292
+ create: true
293
+ }
294
+
295
+ case 'libsql':
296
+ return {
297
+ url: `file:${this.config.databasePath}/${name}.db`,
298
+ ...this.config.libsql
299
+ }
300
+
301
+ case 'odblite':
302
+ return {
303
+ databaseName: `${this.config.databasePath}_${name}`,
304
+ ...this.config.odblite
305
+ }
306
+
307
+ default:
308
+ throw new Error(`Unknown backend: ${this.config.backend}`)
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Evict least recently used connection
314
+ */
315
+ private async evictLRU(): Promise<void> {
316
+ let oldestKey: string | null = null
317
+ let oldestTime = Infinity
318
+
319
+ for (const [key, timestamp] of this.connectionTimestamps) {
320
+ if (timestamp < oldestTime) {
321
+ oldestTime = timestamp
322
+ oldestKey = key
323
+ }
324
+ }
325
+
326
+ if (oldestKey) {
327
+ const conn = this.connections.get(oldestKey)
328
+ if (conn) {
329
+ await conn.close()
330
+ }
331
+ this.connections.delete(oldestKey)
332
+ this.connectionTimestamps.delete(oldestKey)
333
+ console.log(`♻️ Evicted LRU connection: ${oldestKey}`)
334
+ }
335
+ }
336
+ }
@@ -0,0 +1,112 @@
1
+ export interface ParsedStatements {
2
+ pragmaStatements: string[]
3
+ regularStatements: string[]
4
+ }
5
+
6
+ export interface SQLParserOptions {
7
+ separatePragma?: boolean
8
+ }
9
+
10
+ /**
11
+ * Parse SQL file into statements, separating PRAGMA from regular SQL
12
+ * Handles comments, quotes, and semicolons properly
13
+ */
14
+ export function parseSQL(sqlContent: string, options: SQLParserOptions = {}): ParsedStatements {
15
+ const separatePragma = options.separatePragma ?? true
16
+
17
+ // Split by semicolons (node-sql-parser doesn't handle multiple statements well)
18
+ const statements = splitStatements(sqlContent)
19
+
20
+ if (!separatePragma) {
21
+ return {
22
+ pragmaStatements: [],
23
+ regularStatements: statements,
24
+ }
25
+ }
26
+
27
+ const pragmaStatements: string[] = []
28
+ const regularStatements: string[] = []
29
+
30
+ for (const statement of statements) {
31
+ const trimmed = statement.trim().toUpperCase()
32
+ if (trimmed.startsWith('PRAGMA')) {
33
+ pragmaStatements.push(statement)
34
+ } else {
35
+ regularStatements.push(statement)
36
+ }
37
+ }
38
+
39
+ return { pragmaStatements, regularStatements }
40
+ }
41
+
42
+ /**
43
+ * Split SQL content into individual statements
44
+ * Handles comments, quotes, and semicolons properly
45
+ */
46
+ function splitStatements(sqlContent: string): string[] {
47
+ const statements: string[] = []
48
+ let currentStatement = ''
49
+ let inQuote = false
50
+ let quoteChar = ''
51
+
52
+ const lines = sqlContent.split('\n')
53
+
54
+ for (const line of lines) {
55
+ let processedLine = ''
56
+
57
+ for (let i = 0; i < line.length; i++) {
58
+ const char = line[i]
59
+ const prevChar = i > 0 ? line[i - 1] : ''
60
+ const nextChar = i < line.length - 1 ? line[i + 1] : ''
61
+
62
+ // Handle single-line comments
63
+ if (!inQuote && char === '-' && nextChar === '-') {
64
+ break // Skip rest of line
65
+ }
66
+
67
+ // Track quotes
68
+ if ((char === "'" || char === '"') && prevChar !== '\\') {
69
+ if (!inQuote) {
70
+ inQuote = true
71
+ quoteChar = char
72
+ } else if (char === quoteChar) {
73
+ inQuote = false
74
+ quoteChar = ''
75
+ }
76
+ }
77
+
78
+ processedLine += char
79
+
80
+ // Split on semicolon when not in quotes
81
+ if (char === ';' && !inQuote) {
82
+ currentStatement += processedLine
83
+ const stmt = currentStatement.trim()
84
+ if (stmt) {
85
+ statements.push(stmt)
86
+ }
87
+ currentStatement = ''
88
+ processedLine = ''
89
+ }
90
+ }
91
+
92
+ if (processedLine.trim()) {
93
+ currentStatement += `${processedLine}\n`
94
+ }
95
+ }
96
+
97
+ // Handle remaining statement
98
+ const finalStmt = currentStatement.trim()
99
+ if (finalStmt) {
100
+ statements.push(finalStmt)
101
+ }
102
+
103
+ return statements
104
+ }
105
+
106
+ /**
107
+ * Simple split for systems that don't need PRAGMA separation
108
+ */
109
+ export function splitSQLStatements(sqlContent: string): string[] {
110
+ const { pragmaStatements, regularStatements } = parseSQL(sqlContent)
111
+ return [...pragmaStatements, ...regularStatements]
112
+ }
@@ -0,0 +1,140 @@
1
+ import type { Database as BunDatabase } from 'bun:sqlite'
2
+ import type { Client as LibSQLClient } from '@libsql/client'
3
+ import type { ServiceClient } from '../service/service-client'
4
+
5
+ /**
6
+ * Tenancy modes supported by DatabaseManager
7
+ */
8
+ export type TenancyMode =
9
+ | 'single-db' // Single database for entire system (identity, operation)
10
+ | 'multi-tenant' // One database per tenant, no metastore (pipeline)
11
+ | 'metastore' // Metastore + per-tenant databases (wallet)
12
+ | 'dual-mode' // Switchable local/remote (tracking)
13
+
14
+ /**
15
+ * Backend database types
16
+ */
17
+ export type BackendType = 'bun-sqlite' | 'libsql' | 'odblite'
18
+
19
+ /**
20
+ * Query result from any backend
21
+ */
22
+ export interface QueryResult<T = any> {
23
+ rows: T[]
24
+ rowsAffected?: number
25
+ lastInsertRowid?: number | bigint
26
+ }
27
+
28
+ /**
29
+ * Unified database connection interface
30
+ * All backends implement this through adapters
31
+ */
32
+ export interface Connection {
33
+ // Template tag queries (postgres.js-like)
34
+ query<T = any>(strings: TemplateStringsArray, ...values: any[]): Promise<QueryResult<T>>
35
+
36
+ // Standard query methods (supports both formats for LibSQL compatibility)
37
+ execute(sql: string | { sql: string; args?: any[] }, params?: any[]): Promise<QueryResult>
38
+ prepare(sql: string): PreparedStatement
39
+
40
+ // Transaction support
41
+ transaction<T>(fn: (tx: Connection) => Promise<T>): Promise<T>
42
+
43
+ // Connection management
44
+ close(): Promise<void>
45
+ }
46
+
47
+ /**
48
+ * Prepared statement interface
49
+ */
50
+ export interface PreparedStatement {
51
+ execute(params?: any[]): Promise<QueryResult>
52
+ all(params?: any[]): Promise<any[]>
53
+ get(params?: any[]): Promise<any | null>
54
+ }
55
+
56
+ /**
57
+ * Database backend adapter interface
58
+ */
59
+ export interface DatabaseAdapter {
60
+ readonly type: BackendType
61
+
62
+ // Connection management
63
+ connect(config: any): Promise<Connection>
64
+ disconnect(tenantId?: string): Promise<void>
65
+
66
+ // Health check
67
+ isHealthy(): Promise<boolean>
68
+ }
69
+
70
+ /**
71
+ * Migration configuration
72
+ */
73
+ export interface MigrationConfig {
74
+ schemaContent: string // SQL schema content (not file path)
75
+ }
76
+
77
+ /**
78
+ * DatabaseManager configuration
79
+ * Simplified: Manager defines WHERE databases go, createDatabase defines WHAT
80
+ */
81
+ export interface DatabaseManagerConfig {
82
+ // Backend type
83
+ backend: BackendType
84
+
85
+ // Database path (where databases are stored)
86
+ databasePath: string // e.g., './data', '/var/lib/databases', 'tenants' (for ODB-Lite prefix)
87
+
88
+ // Backend-specific options (optional)
89
+ libsql?: {
90
+ encryptionKey?: string
91
+ authToken?: string // For Turso remote databases
92
+ }
93
+
94
+ odblite?: {
95
+ serviceUrl: string
96
+ apiKey: string
97
+ nodeId?: string // Optional: specific node to create databases on (server selects if not provided)
98
+ }
99
+
100
+ // Connection pooling
101
+ connectionPoolSize?: number // Max connections per database (default: 10)
102
+ }
103
+
104
+ /**
105
+ * Bun SQLite adapter config
106
+ */
107
+ export interface BunSQLiteConfig {
108
+ databasePath: string
109
+ readonly?: boolean
110
+ create?: boolean
111
+ }
112
+
113
+ /**
114
+ * LibSQL adapter config
115
+ */
116
+ export interface LibSQLConfig {
117
+ url: string
118
+ authToken?: string
119
+ encryptionKey?: string
120
+ }
121
+
122
+ /**
123
+ * ODB-Lite adapter config
124
+ */
125
+ export interface ODBLiteConfig {
126
+ serviceUrl: string
127
+ apiKey: string
128
+ nodeId?: string // Optional: specific node to create databases on (server selects if not provided)
129
+ }
130
+
131
+ /**
132
+ * Tenant database info
133
+ */
134
+ export interface TenantInfo {
135
+ tenantId: string
136
+ databaseHash?: string // For ODB-Lite
137
+ databasePath?: string // For file-based
138
+ createdAt: Date
139
+ metadata?: Record<string, any>
140
+ }
package/src/index.ts CHANGED
@@ -10,6 +10,16 @@ export { SQLParser, sql, fragment } from './core/sql-parser.ts';
10
10
  // Service management exports (high-level tenant database management)
11
11
  export { ServiceClient } from './service/service-client.ts';
12
12
 
13
+ // Database Manager exports (unified multi-backend database abstraction)
14
+ export {
15
+ DatabaseManager,
16
+ parseSQL,
17
+ splitSQLStatements,
18
+ BunSQLiteAdapter,
19
+ LibSQLAdapter,
20
+ ODBLiteAdapter,
21
+ } from './database/index.ts';
22
+
13
23
  // Export all types
14
24
  export type {
15
25
  ODBLiteConfig,
@@ -30,6 +40,24 @@ export type {
30
40
  ODBLiteNode
31
41
  } from './service/service-client.ts';
32
42
 
43
+ // Export database manager types
44
+ export type {
45
+ TenancyMode,
46
+ BackendType,
47
+ QueryResult as DatabaseQueryResult,
48
+ Connection,
49
+ DatabaseAdapter,
50
+ PreparedStatement as DatabasePreparedStatement,
51
+ DatabaseManagerConfig,
52
+ MigrationConfig,
53
+ BunSQLiteConfig,
54
+ LibSQLConfig,
55
+ ODBLiteConfig as DatabaseODBLiteConfig,
56
+ TenantInfo,
57
+ ParsedStatements,
58
+ SQLParserOptions,
59
+ } from './database/index.ts';
60
+
33
61
  // Export error classes
34
62
  export {
35
63
  ODBLiteError,
@@ -97,8 +97,7 @@ export class ServiceClient {
97
97
  try {
98
98
  // Check if database already exists
99
99
  console.log(`🔍 Checking if database exists: ${cacheKey}`);
100
- const databases = await this.listDatabases();
101
- const existing = databases.find(db => db.name === cacheKey);
100
+ const existing = await this.getDatabaseByName(cacheKey);
102
101
 
103
102
  if (existing) {
104
103
  console.log(`✅ Database already exists: ${cacheKey} (${existing.hash})`);
@@ -159,17 +158,22 @@ export class ServiceClient {
159
158
  * Create a new database
160
159
  *
161
160
  * @param name - Database name (should be unique)
162
- * @param nodeId - ID of the node to host the database
161
+ * @param nodeId - ID of the node to host the database (optional - server will select if null)
163
162
  * @returns Created database object with hash
164
163
  */
165
- async createDatabase(name: string, nodeId: string): Promise<ODBLiteDatabase> {
164
+ async createDatabase(name: string, nodeId?: string | null): Promise<ODBLiteDatabase> {
165
+ const body: any = { name }
166
+ if (nodeId) {
167
+ body.nodeId = nodeId
168
+ }
169
+
166
170
  const response = await fetch(`${this.apiUrl}/api/tenant/databases`, {
167
171
  method: 'POST',
168
172
  headers: {
169
173
  'Authorization': `Bearer ${this.apiKey}`,
170
174
  'Content-Type': 'application/json',
171
175
  },
172
- body: JSON.stringify({ name, nodeId }),
176
+ body: JSON.stringify(body),
173
177
  });
174
178
 
175
179
  const result = await response.json();
@@ -201,6 +205,31 @@ export class ServiceClient {
201
205
  return result.database;
202
206
  }
203
207
 
208
+ /**
209
+ * Get database details by name
210
+ *
211
+ * @param name - Database name
212
+ * @returns Database object or null if not found
213
+ */
214
+ async getDatabaseByName(name: string): Promise<ODBLiteDatabase | null> {
215
+ const response = await fetch(`${this.apiUrl}/api/tenant/databases/by-name/${name}`, {
216
+ headers: {
217
+ 'Authorization': `Bearer ${this.apiKey}`,
218
+ },
219
+ });
220
+
221
+ if (response.status === 404) {
222
+ return null;
223
+ }
224
+
225
+ const result = await response.json();
226
+ if (!result.success) {
227
+ throw new Error(result.error || 'Failed to get database');
228
+ }
229
+
230
+ return result.database;
231
+ }
232
+
204
233
  /**
205
234
  * Delete a database
206
235
  *