diff --git a/packages/objectql/CHANGELOG.md b/packages/objectql/CHANGELOG.md index c022965df..bd9793f06 100644 --- a/packages/objectql/CHANGELOG.md +++ b/packages/objectql/CHANGELOG.md @@ -1,5 +1,11 @@ # @objectstack/objectql +## 4.0.3 + +### Patch Changes + +- fix: ObjectQL.init() now tracks and warns about failed driver connections instead of silently swallowing errors, improving debuggability for cold-start and serverless issues. + ## 4.0.2 ### Patch Changes diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index c56f5d45f..7f11038a4 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -594,14 +594,24 @@ export class ObjectQL implements IDataEngine { drivers: Array.from(this.drivers.keys()) }); + const failedDrivers: string[] = []; for (const [name, driver] of this.drivers) { try { await driver.connect(); this.logger.info('Driver connected successfully', { driverName: name }); } catch (e) { + failedDrivers.push(name); this.logger.error('Failed to connect driver', e as Error, { driverName: name }); } } + + if (failedDrivers.length > 0) { + this.logger.warn( + `${failedDrivers.length} of ${this.drivers.size} driver(s) failed initial connect. ` + + `Operations may recover via lazy reconnection or fail at query time.`, + { failedDrivers } + ); + } this.logger.info('ObjectQL engine initialization complete'); } diff --git a/packages/plugins/driver-turso/CHANGELOG.md b/packages/plugins/driver-turso/CHANGELOG.md index 2270f2ce6..094c89a11 100644 --- a/packages/plugins/driver-turso/CHANGELOG.md +++ b/packages/plugins/driver-turso/CHANGELOG.md @@ -1,5 +1,11 @@ # @objectstack/driver-turso +## 4.0.3 + +### Patch Changes + +- fix: implement lazy connect in RemoteTransport to self-heal from serverless cold-start failures, transient network errors, or missed `connect()` calls. The transport now accepts a connect factory and auto-initializes the @libsql/client on first operation when the client is not yet available. Concurrent reconnection attempts are de-duplicated. + ## 4.0.2 ### Patch Changes diff --git a/packages/plugins/driver-turso/src/remote-transport.ts b/packages/plugins/driver-turso/src/remote-transport.ts index 138b00aca..dbf36570d 100644 --- a/packages/plugins/driver-turso/src/remote-transport.ts +++ b/packages/plugins/driver-turso/src/remote-transport.ts @@ -42,6 +42,22 @@ const SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/; export class RemoteTransport { private client: Client | null = null; + /** + * Factory function for lazy (re)connection. + * + * When set, `ensureConnected()` will invoke this factory to create a + * @libsql/client instance on-demand — recovering from cold-start failures, + * transient network errors, or serverless recycling without requiring the + * caller to explicitly call `connect()` again. + */ + private connectFactory: (() => Promise) | null = null; + + /** + * Tracks whether a lazy-connect attempt is already in progress to prevent + * concurrent reconnection storms under high concurrency. + */ + private connectPromise: Promise | null = null; + /** * Set the @libsql/client instance used for all queries. */ @@ -49,6 +65,17 @@ export class RemoteTransport { this.client = client; } + /** + * Register a factory function for lazy (re)connection. + * + * TursoDriver calls this during construction so that the transport can + * self-heal when the initial `connect()` call fails or when the client + * becomes unavailable (e.g., serverless cold-start, transient error). + */ + setConnectFactory(factory: () => Promise): void { + this.connectFactory = factory; + } + /** * Get the current @libsql/client instance. */ @@ -71,9 +98,9 @@ export class RemoteTransport { // =================================== async checkHealth(): Promise { - if (!this.client) return false; try { - await this.client.execute('SELECT 1'); + const client = await this.ensureConnected(); + await client.execute('SELECT 1'); return true; } catch { return false; @@ -85,7 +112,7 @@ export class RemoteTransport { // =================================== async execute(command: unknown, params?: unknown[]): Promise { - this.ensureClient(); + await this.ensureConnected(); if (typeof command !== 'string') return command; const stmt: InStatement = params && params.length > 0 @@ -101,7 +128,7 @@ export class RemoteTransport { // =================================== async find(object: string, query: any): Promise[]> { - this.ensureClient(); + await this.ensureConnected(); const { sql, args } = this.buildSelectSQL(object, query); @@ -123,7 +150,7 @@ export class RemoteTransport { async findOne(object: string, query: any): Promise | null> { // When called with a string/number id fall back gracefully if (typeof query === 'string' || typeof query === 'number') { - this.ensureClient(); + await this.ensureConnected(); const result = await this.client!.execute({ sql: `SELECT * FROM "${object}" WHERE "id" = ? LIMIT 1`, args: [query], @@ -148,7 +175,7 @@ export class RemoteTransport { } async create(object: string, data: Record): Promise> { - this.ensureClient(); + await this.ensureConnected(); const { _id, ...rest } = data as any; const toInsert = { ...rest }; @@ -176,7 +203,7 @@ export class RemoteTransport { } async update(object: string, id: string | number, data: Record): Promise> { - this.ensureClient(); + await this.ensureConnected(); const columns = Object.keys(data); const setClauses = columns.map((col) => `"${col}" = ?`).join(', '); @@ -195,7 +222,7 @@ export class RemoteTransport { } async upsert(object: string, data: Record, conflictKeys?: string[]): Promise> { - this.ensureClient(); + await this.ensureConnected(); const { _id, ...rest } = data as any; const toUpsert = { ...rest }; @@ -235,7 +262,7 @@ export class RemoteTransport { } async delete(object: string, id: string | number): Promise { - this.ensureClient(); + await this.ensureConnected(); const result = await this.client!.execute({ sql: `DELETE FROM "${object}" WHERE "id" = ?`, args: [id], @@ -244,7 +271,7 @@ export class RemoteTransport { } async count(object: string, query?: any): Promise { - this.ensureClient(); + await this.ensureConnected(); const { whereClauses, args } = this.buildWhereSQL(query?.where); let sql = `SELECT COUNT(*) as count FROM "${object}"`; @@ -283,7 +310,7 @@ export class RemoteTransport { } async bulkDelete(object: string, ids: Array): Promise { - this.ensureClient(); + await this.ensureConnected(); if (ids.length === 0) return; const placeholders = ids.map(() => '?').join(', '); @@ -294,7 +321,7 @@ export class RemoteTransport { } async updateMany(object: string, query: any, data: Record): Promise { - this.ensureClient(); + await this.ensureConnected(); const columns = Object.keys(data); const setClauses = columns.map((col) => `"${col}" = ?`).join(', '); @@ -309,7 +336,7 @@ export class RemoteTransport { } async deleteMany(object: string, query: any): Promise { - this.ensureClient(); + await this.ensureConnected(); const { whereClauses, args } = this.buildWhereSQL(query?.where); let sql = `DELETE FROM "${object}"`; @@ -324,7 +351,7 @@ export class RemoteTransport { // =================================== async beginTransaction(): Promise { - this.ensureClient(); + await this.ensureConnected(); return this.client!.transaction(); } @@ -341,7 +368,7 @@ export class RemoteTransport { // =================================== async syncSchema(object: string, schema: any): Promise { - this.ensureClient(); + await this.ensureConnected(); const objectDef = schema as { name: string; fields?: Record }; const tableName = object; @@ -391,7 +418,7 @@ export class RemoteTransport { * by the caller if a batch operation is not supported or fails. */ async syncSchemasBatch(schemas: Array<{ object: string; schema: any }>): Promise { - this.ensureClient(); + await this.ensureConnected(); if (schemas.length === 0) return; // Validate all identifiers up-front @@ -459,7 +486,7 @@ export class RemoteTransport { } async dropTable(object: string): Promise { - this.ensureClient(); + await this.ensureConnected(); await this.client!.execute(`DROP TABLE IF EXISTS "${object}"`); } @@ -467,11 +494,37 @@ export class RemoteTransport { // Internal Helpers // =================================== - private ensureClient(): Client { - if (!this.client) { - throw new Error('RemoteTransport: @libsql/client is not initialized. Call connect() first.'); + /** + * Ensure the @libsql/client is initialized, attempting lazy connect if a + * factory was registered and the client is not yet available. + * + * Uses a singleton promise to prevent concurrent reconnection storms: + * multiple callers that race into this method while a connect is in flight + * will all await the same promise. + */ + private async ensureConnected(): Promise { + if (this.client) return this.client; + + if (this.connectFactory) { + // De-duplicate concurrent connect attempts + if (!this.connectPromise) { + this.connectPromise = this.connectFactory() + .then((client) => { + this.client = client; + this.connectPromise = null; + return client; + }) + .catch((err) => { + this.connectPromise = null; + throw new Error( + `RemoteTransport: lazy connect failed: ${err instanceof Error ? err.message : String(err)}` + ); + }); + } + return this.connectPromise; } - return this.client; + + throw new Error('RemoteTransport: @libsql/client is not initialized. Call connect() first.'); } /** diff --git a/packages/plugins/driver-turso/src/turso-driver.test.ts b/packages/plugins/driver-turso/src/turso-driver.test.ts index 591ce7ffc..bbecda604 100644 --- a/packages/plugins/driver-turso/src/turso-driver.test.ts +++ b/packages/plugins/driver-turso/src/turso-driver.test.ts @@ -911,3 +911,147 @@ describe('TursoDriver Remote Mode (via @libsql/client)', () => { await expect(driver.syncSchemasBatch([])).resolves.not.toThrow(); }); }); + +// ── Lazy Connect (self-healing for serverless cold starts) ─────────────────── + +describe('TursoDriver Remote Mode — Lazy Connect', () => { + it('should lazy-connect on first find when connect() was never called', async () => { + const { createClient } = await import('@libsql/client'); + const memClient = createClient({ url: 'file::memory:' }); + + await memClient.execute(` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + name TEXT + ) + `); + await memClient.execute({ sql: `INSERT INTO users (id, name) VALUES (?, ?)`, args: ['1', 'Alice'] }); + + // Create driver but intentionally do NOT call connect() + const driver = new TursoDriver({ + url: 'libsql://test.turso.io', + authToken: 'test-token', + client: memClient, + }); + + // The first CRUD operation should trigger lazy connect via the factory + const results = await driver.find('users', {}); + expect(results.length).toBe(1); + expect((results[0] as any).name).toBe('Alice'); + + // Client should now be connected + expect(driver.getLibsqlClient()).not.toBeNull(); + await driver.disconnect(); + }); + + it('should lazy-connect on first create when connect() was never called', async () => { + const { createClient } = await import('@libsql/client'); + const memClient = createClient({ url: 'file::memory:' }); + + await memClient.execute(` + CREATE TABLE IF NOT EXISTS items ( + id TEXT PRIMARY KEY, + title TEXT + ) + `); + + const driver = new TursoDriver({ + url: 'libsql://test.turso.io', + authToken: 'test-token', + client: memClient, + }); + + // No connect() — should lazy-connect + const item = await driver.create('items', { id: 'x', title: 'Test' }); + expect(item.title).toBe('Test'); + await driver.disconnect(); + }); + + it('should lazy-connect on checkHealth when connect() was never called', async () => { + const { createClient } = await import('@libsql/client'); + const memClient = createClient({ url: 'file::memory:' }); + + const driver = new TursoDriver({ + url: 'libsql://test.turso.io', + authToken: 'test-token', + client: memClient, + }); + + // checkHealth should trigger lazy connect and succeed + const healthy = await driver.checkHealth(); + expect(healthy).toBe(true); + await driver.disconnect(); + }); + + it('should de-duplicate concurrent lazy-connect attempts', async () => { + const { createClient } = await import('@libsql/client'); + const memClient = createClient({ url: 'file::memory:' }); + + await memClient.execute(` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + name TEXT + ) + `); + await memClient.execute({ sql: `INSERT INTO users (id, name) VALUES (?, ?)`, args: ['1', 'Alice'] }); + + const driver = new TursoDriver({ + url: 'libsql://test.turso.io', + authToken: 'test-token', + client: memClient, + }); + + // Fire multiple operations concurrently without calling connect() + const [r1, r2, r3] = await Promise.all([ + driver.find('users', {}), + driver.find('users', {}), + driver.count('users'), + ]); + + expect(r1.length).toBe(1); + expect(r2.length).toBe(1); + expect(r3).toBe(1); + await driver.disconnect(); + }); + + it('should recover when transport client is cleared', async () => { + const { createClient } = await import('@libsql/client'); + + // We create two separate clients: one to simulate the "lost" state, and + // a fresh one that the lazy factory should produce on reconnect. + const memClient1 = createClient({ url: 'file::memory:' }); + const memClient2 = createClient({ url: 'file::memory:' }); + + await memClient2.execute(` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + name TEXT + ) + `); + await memClient2.execute({ sql: `INSERT INTO users (id, name) VALUES (?, ?)`, args: ['1', 'Bob'] }); + + const driver = new TursoDriver({ + url: 'libsql://test.turso.io', + authToken: 'test-token', + client: memClient1, + }); + + await driver.connect(); + expect(driver.getLibsqlClient()).not.toBeNull(); + + // Clear only the transport's reference (simulates stale state) and point + // the factory at a fresh, working client. + const transport = driver.getRemoteTransport()!; + transport.setClient(null as unknown as any); + // Override the factory to return the second client + transport.setConnectFactory(async () => memClient2); + + // Next operation should re-connect via the factory + const results = await driver.find('users', {}); + expect(results.length).toBe(1); + expect((results[0] as any).name).toBe('Bob'); + + memClient1.close(); + memClient2.close(); + }); +}); diff --git a/packages/plugins/driver-turso/src/turso-driver.ts b/packages/plugins/driver-turso/src/turso-driver.ts index 1d5724c68..9f473e2ca 100644 --- a/packages/plugins/driver-turso/src/turso-driver.ts +++ b/packages/plugins/driver-turso/src/turso-driver.ts @@ -238,6 +238,23 @@ export class TursoDriver extends SqlDriver { if (mode === 'remote') { this.remoteTransport = new RemoteTransport(); + + // Register a lazy-connect factory so the transport can self-heal when + // connect() was never called, failed on first attempt, or the client + // was lost (e.g. serverless cold-start, transient network error). + this.remoteTransport.setConnectFactory(async () => { + if (this.tursoConfig.client) { + this.libsqlClient = this.tursoConfig.client; + } else { + const { createClient } = await import('@libsql/client'); + this.libsqlClient = createClient({ + url: this.tursoConfig.url, + authToken: this.tursoConfig.authToken, + concurrency: this.tursoConfig.concurrency, + }); + } + return this.libsqlClient; + }); } }