Skip to content

Commit 0ae9b42

Browse files
authored
Merge pull request #1085 from objectstack-ai/copilot/fix-remote-transport-initialization
2 parents 7acb6d0 + 222fefe commit 0ae9b42

File tree

6 files changed

+257
-21
lines changed

6 files changed

+257
-21
lines changed

packages/objectql/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# @objectstack/objectql
22

3+
## 4.0.3
4+
5+
### Patch Changes
6+
7+
- fix: ObjectQL.init() now tracks and warns about failed driver connections instead of silently swallowing errors, improving debuggability for cold-start and serverless issues.
8+
39
## 4.0.2
410

511
### Patch Changes

packages/objectql/src/engine.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -594,14 +594,24 @@ export class ObjectQL implements IDataEngine {
594594
drivers: Array.from(this.drivers.keys())
595595
});
596596

597+
const failedDrivers: string[] = [];
597598
for (const [name, driver] of this.drivers) {
598599
try {
599600
await driver.connect();
600601
this.logger.info('Driver connected successfully', { driverName: name });
601602
} catch (e) {
603+
failedDrivers.push(name);
602604
this.logger.error('Failed to connect driver', e as Error, { driverName: name });
603605
}
604606
}
607+
608+
if (failedDrivers.length > 0) {
609+
this.logger.warn(
610+
`${failedDrivers.length} of ${this.drivers.size} driver(s) failed initial connect. ` +
611+
`Operations may recover via lazy reconnection or fail at query time.`,
612+
{ failedDrivers }
613+
);
614+
}
605615

606616
this.logger.info('ObjectQL engine initialization complete');
607617
}

packages/plugins/driver-turso/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# @objectstack/driver-turso
22

3+
## 4.0.3
4+
5+
### Patch Changes
6+
7+
- 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.
8+
39
## 4.0.2
410

511
### Patch Changes

packages/plugins/driver-turso/src/remote-transport.ts

Lines changed: 74 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,40 @@ const SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
4242
export class RemoteTransport {
4343
private client: Client | null = null;
4444

45+
/**
46+
* Factory function for lazy (re)connection.
47+
*
48+
* When set, `ensureConnected()` will invoke this factory to create a
49+
* @libsql/client instance on-demand — recovering from cold-start failures,
50+
* transient network errors, or serverless recycling without requiring the
51+
* caller to explicitly call `connect()` again.
52+
*/
53+
private connectFactory: (() => Promise<Client>) | null = null;
54+
55+
/**
56+
* Tracks whether a lazy-connect attempt is already in progress to prevent
57+
* concurrent reconnection storms under high concurrency.
58+
*/
59+
private connectPromise: Promise<Client> | null = null;
60+
4561
/**
4662
* Set the @libsql/client instance used for all queries.
4763
*/
4864
setClient(client: Client): void {
4965
this.client = client;
5066
}
5167

68+
/**
69+
* Register a factory function for lazy (re)connection.
70+
*
71+
* TursoDriver calls this during construction so that the transport can
72+
* self-heal when the initial `connect()` call fails or when the client
73+
* becomes unavailable (e.g., serverless cold-start, transient error).
74+
*/
75+
setConnectFactory(factory: () => Promise<Client>): void {
76+
this.connectFactory = factory;
77+
}
78+
5279
/**
5380
* Get the current @libsql/client instance.
5481
*/
@@ -71,9 +98,9 @@ export class RemoteTransport {
7198
// ===================================
7299

73100
async checkHealth(): Promise<boolean> {
74-
if (!this.client) return false;
75101
try {
76-
await this.client.execute('SELECT 1');
102+
const client = await this.ensureConnected();
103+
await client.execute('SELECT 1');
77104
return true;
78105
} catch {
79106
return false;
@@ -85,7 +112,7 @@ export class RemoteTransport {
85112
// ===================================
86113

87114
async execute(command: unknown, params?: unknown[]): Promise<unknown> {
88-
this.ensureClient();
115+
await this.ensureConnected();
89116
if (typeof command !== 'string') return command;
90117

91118
const stmt: InStatement = params && params.length > 0
@@ -101,7 +128,7 @@ export class RemoteTransport {
101128
// ===================================
102129

103130
async find(object: string, query: any): Promise<Record<string, unknown>[]> {
104-
this.ensureClient();
131+
await this.ensureConnected();
105132

106133
const { sql, args } = this.buildSelectSQL(object, query);
107134

@@ -123,7 +150,7 @@ export class RemoteTransport {
123150
async findOne(object: string, query: any): Promise<Record<string, unknown> | null> {
124151
// When called with a string/number id fall back gracefully
125152
if (typeof query === 'string' || typeof query === 'number') {
126-
this.ensureClient();
153+
await this.ensureConnected();
127154
const result = await this.client!.execute({
128155
sql: `SELECT * FROM "${object}" WHERE "id" = ? LIMIT 1`,
129156
args: [query],
@@ -148,7 +175,7 @@ export class RemoteTransport {
148175
}
149176

150177
async create(object: string, data: Record<string, unknown>): Promise<Record<string, unknown>> {
151-
this.ensureClient();
178+
await this.ensureConnected();
152179

153180
const { _id, ...rest } = data as any;
154181
const toInsert = { ...rest };
@@ -176,7 +203,7 @@ export class RemoteTransport {
176203
}
177204

178205
async update(object: string, id: string | number, data: Record<string, unknown>): Promise<Record<string, unknown>> {
179-
this.ensureClient();
206+
await this.ensureConnected();
180207

181208
const columns = Object.keys(data);
182209
const setClauses = columns.map((col) => `"${col}" = ?`).join(', ');
@@ -195,7 +222,7 @@ export class RemoteTransport {
195222
}
196223

197224
async upsert(object: string, data: Record<string, unknown>, conflictKeys?: string[]): Promise<Record<string, unknown>> {
198-
this.ensureClient();
225+
await this.ensureConnected();
199226

200227
const { _id, ...rest } = data as any;
201228
const toUpsert = { ...rest };
@@ -235,7 +262,7 @@ export class RemoteTransport {
235262
}
236263

237264
async delete(object: string, id: string | number): Promise<boolean> {
238-
this.ensureClient();
265+
await this.ensureConnected();
239266
const result = await this.client!.execute({
240267
sql: `DELETE FROM "${object}" WHERE "id" = ?`,
241268
args: [id],
@@ -244,7 +271,7 @@ export class RemoteTransport {
244271
}
245272

246273
async count(object: string, query?: any): Promise<number> {
247-
this.ensureClient();
274+
await this.ensureConnected();
248275

249276
const { whereClauses, args } = this.buildWhereSQL(query?.where);
250277
let sql = `SELECT COUNT(*) as count FROM "${object}"`;
@@ -283,7 +310,7 @@ export class RemoteTransport {
283310
}
284311

285312
async bulkDelete(object: string, ids: Array<string | number>): Promise<void> {
286-
this.ensureClient();
313+
await this.ensureConnected();
287314
if (ids.length === 0) return;
288315

289316
const placeholders = ids.map(() => '?').join(', ');
@@ -294,7 +321,7 @@ export class RemoteTransport {
294321
}
295322

296323
async updateMany(object: string, query: any, data: Record<string, unknown>): Promise<number> {
297-
this.ensureClient();
324+
await this.ensureConnected();
298325

299326
const columns = Object.keys(data);
300327
const setClauses = columns.map((col) => `"${col}" = ?`).join(', ');
@@ -309,7 +336,7 @@ export class RemoteTransport {
309336
}
310337

311338
async deleteMany(object: string, query: any): Promise<number> {
312-
this.ensureClient();
339+
await this.ensureConnected();
313340

314341
const { whereClauses, args } = this.buildWhereSQL(query?.where);
315342
let sql = `DELETE FROM "${object}"`;
@@ -324,7 +351,7 @@ export class RemoteTransport {
324351
// ===================================
325352

326353
async beginTransaction(): Promise<any> {
327-
this.ensureClient();
354+
await this.ensureConnected();
328355
return this.client!.transaction();
329356
}
330357

@@ -341,7 +368,7 @@ export class RemoteTransport {
341368
// ===================================
342369

343370
async syncSchema(object: string, schema: any): Promise<void> {
344-
this.ensureClient();
371+
await this.ensureConnected();
345372

346373
const objectDef = schema as { name: string; fields?: Record<string, any> };
347374
const tableName = object;
@@ -391,7 +418,7 @@ export class RemoteTransport {
391418
* by the caller if a batch operation is not supported or fails.
392419
*/
393420
async syncSchemasBatch(schemas: Array<{ object: string; schema: any }>): Promise<void> {
394-
this.ensureClient();
421+
await this.ensureConnected();
395422
if (schemas.length === 0) return;
396423

397424
// Validate all identifiers up-front
@@ -459,19 +486,45 @@ export class RemoteTransport {
459486
}
460487

461488
async dropTable(object: string): Promise<void> {
462-
this.ensureClient();
489+
await this.ensureConnected();
463490
await this.client!.execute(`DROP TABLE IF EXISTS "${object}"`);
464491
}
465492

466493
// ===================================
467494
// Internal Helpers
468495
// ===================================
469496

470-
private ensureClient(): Client {
471-
if (!this.client) {
472-
throw new Error('RemoteTransport: @libsql/client is not initialized. Call connect() first.');
497+
/**
498+
* Ensure the @libsql/client is initialized, attempting lazy connect if a
499+
* factory was registered and the client is not yet available.
500+
*
501+
* Uses a singleton promise to prevent concurrent reconnection storms:
502+
* multiple callers that race into this method while a connect is in flight
503+
* will all await the same promise.
504+
*/
505+
private async ensureConnected(): Promise<Client> {
506+
if (this.client) return this.client;
507+
508+
if (this.connectFactory) {
509+
// De-duplicate concurrent connect attempts
510+
if (!this.connectPromise) {
511+
this.connectPromise = this.connectFactory()
512+
.then((client) => {
513+
this.client = client;
514+
this.connectPromise = null;
515+
return client;
516+
})
517+
.catch((err) => {
518+
this.connectPromise = null;
519+
throw new Error(
520+
`RemoteTransport: lazy connect failed: ${err instanceof Error ? err.message : String(err)}`
521+
);
522+
});
523+
}
524+
return this.connectPromise;
473525
}
474-
return this.client;
526+
527+
throw new Error('RemoteTransport: @libsql/client is not initialized. Call connect() first.');
475528
}
476529

477530
/**

0 commit comments

Comments
 (0)